diff --git a/karma.conf.js b/karma.conf.js index 751ef783..f7666b03 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -18,7 +18,7 @@ module.exports = function (config) { served: true, }, { - pattern: 'lib/events/util.js', + pattern: 'lib/**/util.js', type: 'module', included: true, served: true, diff --git a/lib/contextMenu.js b/lib/contextMenu.js index 12308cf5..394cef94 100644 --- a/lib/contextMenu.js +++ b/lib/contextMenu.js @@ -538,8 +538,114 @@ export default function (self) { }); } } + + /** + * Return a tuple if the user selected contiguous columns, otherwise `null`. + * Info: Because the user may reorder the columns, + * the schemaIndex of the first item may be greater than the schemaIndex of the second item, + * but the columnIndex of the firs item must less than the columnIndex of the second item. + * @param {any[]} schema from `self.getSchema()` + * @returns {any[]} column schemas tuple (each schema has an additional field `schemaIndex`) + */ + function getSelectedContiguousColumns(ev, schema) { + const memoKey = '__contiguousColumns'; + if (Array.isArray(ev[memoKey]) || ev[memoKey] === null) return ev[memoKey]; + ev[memoKey] = null; + + if (!Array.isArray(self.selections) || self.selections.length === 0) return; + const selection = self.selections[0]; + if (!selection || selection.length === 0) return; + for (let rowIndex = 0; rowIndex < self.viewData.length; rowIndex++) { + const row = self.viewData[rowIndex]; + if (!row) continue; + const compare = self.selections[rowIndex]; + if (!compare) return; + if (compare.length !== selection.length) return; + for (let i = 0; i < selection.length; i++) + if (selection[i] !== compare[i]) return; + } + selection.sort((a, b) => a - b); + + /** @type {number[][]} */ + const ranges = []; + let begin = selection[0]; + let end = selection[0]; + for (let i = 1; i < selection.length; i++) { + const orderIndex = selection[i]; + if (orderIndex === end + 1) { + end = orderIndex; + continue; + } + ranges.push([begin, end]); + begin = orderIndex; + end = orderIndex; + } + ranges.push([begin, end]); + + const currentOrderIndex = ev.cell.columnIndex; + const matchedRange = ranges.find( + (range) => + currentOrderIndex >= range[0] && + currentOrderIndex <= range[1] && + range[0] !== range[1], + ); + if (!matchedRange) return; + + /** @type {number[]} orders[index] => columnIndex */ + const orders = self.orders.columns; + if (!Array.isArray(orders)) return; + + const matchedSchema = matchedRange.map((orderIndex) => { + const schemaIndex = orders[orderIndex]; + const thisSchema = schema[schemaIndex]; + return Object.assign({}, thisSchema, { orderIndex }); + }); + if (matchedSchema.findIndex((it) => !it) >= 0) return; + return (ev[memoKey] = matchedSchema); + } + /** + * @param {boolean} [allowOnlyOneRow] + * @returns {number[]} a rowIndex tuple. It can contains one row index or two row indexes. + */ + function getSelectedContiguousRows(allowOnlyOneRow) { + let range = []; + let prev = -2; + let ok = true; + self.selections.forEach(function (row, index) { + if (!ok) return; + if (prev < -1) { + prev = index; + range[0] = index; + return; + } + if (index !== prev + 1 || !row || row.length === 0) { + ok = false; + return; + } + prev = index; + range[1] = index; + }); + if (ok) { + if (range.length === 1) return allowOnlyOneRow ? range : null; + return range; + } + } function addDefaultContextMenuItem(e) { - var isNormalCell = + const schema = self.getSchema(); + /** + * A map between columnIndex and column data + * @type {Map} + */ + let columns; + const getColumnsMap = () => { + if (!columns) + columns = new Map(schema.map((_col) => [_col.columnIndex, _col])); + return columns; + }; + const isSorting = + self.orderings.columns && self.orderings.columns.length > 0; + + const isNormalCell = !( e.cell.isBackground || e.cell.isColumnHeaderCellCap || @@ -614,20 +720,30 @@ export default function (self) { const columnOrderIndex = e.cell.columnIndex; const columnIndex = self.orders.columns[columnOrderIndex]; + const contiguousColumns = getSelectedContiguousColumns(e, schema); + let title = ''; + if (contiguousColumns) { + title = contiguousColumns + .map((col) => col.title || col.name) + .join('-'); + } else { + const column = schema[columnIndex]; + if (column) title = column.title || column.name; + } e.items.push({ - title: self.attributes.hideColumnText.replace( - /%s/gi, - e.cell.header.title || e.cell.header.name, - ), + title: self.attributes.hideColumnText.replace(/%s/gi, title), click: function (ev) { - self.getSchema()[columnIndex].hidden = true; ev.preventDefault(); self.stopPropagation(ev); self.disposeContextMenu(); - self.setStorageData(); - setTimeout(function () { - self.resize(true); - }, 10); + if (contiguousColumns) { + self.hideColumns( + contiguousColumns[0].orderIndex, + contiguousColumns[1].orderIndex, + ); + } else { + self.hideColumns(columnOrderIndex); + } }, }); } @@ -689,6 +805,51 @@ export default function (self) { }, }); } + + //#region hide rows + const canHideRows = !isSorting && e.cell.isRowHeader && e.cell.header; + if (canHideRows) { + const range = getSelectedContiguousRows(true); + if (range) { + const boundRowIndexes = range.map((viewRowIndex) => + self.getBoundRowIndexFromViewRowIndex(viewRowIndex), + ); + let title; + if (boundRowIndexes.length === 1) { + if (typeof boundRowIndexes[0] === 'number') + title = boundRowIndexes[0] + 1; + else title = range[0] + 1; + + title = self.attributes.showHideRow.replace('%s', title); + // hide one row + e.items.push({ + title, + click: function (ev) { + ev.preventDefault(); + self.hideRows(boundRowIndexes[0], boundRowIndexes[0]); + }, + }); + } else if (boundRowIndexes[0] <= boundRowIndexes[1]) { + title = boundRowIndexes + .map((it, index) => { + if (typeof it === 'number') return it + 1; + return range[index] + 1; + }) + .join('-'); + title = self.attributes.showHideRows.replace('%s', title); + // hide rows + e.items.push({ + title, + click: function (ev) { + ev.preventDefault(); + self.hideRows(boundRowIndexes[0], boundRowIndexes[1]); + }, + }); + } + } + } + //#endregion hide rows + //#region group/ungroup columns const groupAreaHeight = self.getColumnGroupAreaHeight(); const groupAreaWidth = self.getRowGroupAreaWidth(); @@ -760,28 +921,13 @@ export default function (self) { const canUngroupColumns = self.attributes.allowGroupingColumns && e.cell.isColumnHeader; const canGroupByRows = - self.attributes.allowGroupingRows && e.cell.isRowHeader && e.cell.header; + !isSorting && + self.attributes.allowGroupingRows && + e.cell.isRowHeader && + e.cell.header; const canUngroupRows = self.attributes.allowGroupingRows && e.cell.isRowHeader; - /** - * The value for storing the return value from `self.getSchema()` - * @type {any[]} - */ - let schema; - /** - * A map between columnIndex and column data - * @type {Map} - */ - let columns; - const getColumnsMap = () => { - if (!columns) { - if (!schema) schema = self.getSchema(); - columns = new Map(schema.map((_col) => [_col.columnIndex, _col])); - } - return columns; - }; - if (canGroupByColumns) { /** @type {number[]} */ const groupIndexes = []; @@ -863,23 +1009,7 @@ export default function (self) { } } if (canGroupByRows) { - let range = []; - let prev = -2; - let ok = true; - self.selections.forEach(function (row, index) { - if (!ok) return; - if (prev < -1) { - prev = index; - range[0] = index; - return; - } - if (index !== prev + 1 || !row || row.length === 0) { - ok = false; - return; - } - prev = index; - range[1] = index; - }); + const range = getSelectedContiguousRows(false) || []; const rangeTitle = range .map((rowIndex) => { const index = self.getBoundRowIndexFromViewRowIndex(rowIndex); @@ -888,7 +1018,6 @@ export default function (self) { }) .join('-'); if ( - ok && range.length === 2 && self.isNewGroupRangeValid(self.groupedRows, range[0], range[1]) ) { diff --git a/lib/defaults.js b/lib/defaults.js index 15c0b926..4a01951a 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -45,6 +45,10 @@ export default function (self) { ['filterFrozenRows', true], ['globalRowResize', false], ['hideColumnText', 'Hide %s'], + ['showUnhideColumnsIndicator', false], + ['showUnhideRowsIndicator', false], + ['showHideRow', 'Hide row %s'], + ['showHideRows', 'Hide rows %s'], ['hoverMode', 'cell'], ['keepFocusOnMouseOut', false], ['maxAutoCompleteItems', 200], @@ -377,6 +381,10 @@ export default function (self) { ['treeArrowMarginTop', 6], ['treeArrowWidth', 13], ['treeGridHeight', 250], + ['unhideIndicatorColor', 'rgba(0, 0, 0, 1)'], + ['unhideIndicatorBackgroundColor', 'rgba(255, 255, 255, 1)'], + ['unhideIndicatorBorderColor', 'rgba(174, 193, 232, 1)'], + ['unhideIndicatorSize', 16], ['width', 'auto'], ], }; diff --git a/lib/draw.js b/lib/draw.js index 265581b9..356e0029 100644 --- a/lib/draw.js +++ b/lib/draw.js @@ -110,8 +110,9 @@ export default function (self) { } /** * @param {number[]} coords [x0,y0, x1,y1, x2,y2, ...] + * @param {boolean} [fill] fill the area that construct by these lines but not stroke */ - function strokeLines(coords) { + function drawLines(coords, fill) { if (coords.length < 4) return; self.ctx.beginPath(); self.ctx.moveTo( @@ -123,7 +124,8 @@ export default function (self) { const y = coords[i + 1] + self.canvasOffsetTop; self.ctx.lineTo(x, y); } - self.ctx.stroke(); + if (fill) self.ctx.fill(); + else self.ctx.stroke(); } /** * @param {number} x based-X (left-top) @@ -136,8 +138,67 @@ export default function (self) { strokeRect(x, y, width, width); const cx = x + width * 0.5; const cy = y + width * 0.5; - strokeLines([x + width * 0.2, cy, x + width * 0.78, cy]); - if (collapsed) strokeLines([cx, y + width * 0.22, cx, y + width * 0.8]); + drawLines([x + width * 0.2, cy, x + width * 0.78, cy]); + if (collapsed) drawLines([cx, y + width * 0.22, cx, y + width * 0.8]); + } + /** + * @param {number} x + * @param {number} y + * @param {number} size + * @param {string} dir Direction of the triangle, one of the 't','b','l' and 'r' + * @param {boolean} [active] + */ + function drawUnhideIndicator(x, y, size, dir, active) { + const minPadding = size * 0.2; + const maxPadding = size * 0.3; + /** The long edge width of the triangle */ + const longEdge = size - 2 * minPadding; + /** The median width of the triangle */ + const median = size - 2 * maxPadding; + const halfLongEdge = longEdge * 0.5; + let x0, y0; + let coords, borderCoords; + switch (dir) { + case 'r': + x0 = x + maxPadding; + y0 = y + minPadding; + borderCoords = [x, y, x + size, y, x + size, y + size, x, y + size]; + coords = [x0, y0, x0, y0 + longEdge, x0 + median, y0 + halfLongEdge]; + break; + case 'l': + x0 = x + size - maxPadding; + y0 = y + minPadding; + borderCoords = [x + size, y, x, y, x, y + size, x + size, y + size]; + coords = [x0, y0, x0, y0 + longEdge, x0 - median, y0 + halfLongEdge]; + break; + case 't': + x0 = x + minPadding; + y0 = y + size - maxPadding; + borderCoords = [x, y + size, x, y, x + size, y, x + size, y + size]; + coords = [x0, y0, x0 + longEdge, y0, x0 + halfLongEdge, y0 - median]; + break; + case 'b': + x0 = x + minPadding; + y0 = y + maxPadding; + borderCoords = [x, y, x, y + size, x + size, y + size, x + size, y]; + coords = [x0, y0, x0 + longEdge, y0, x0 + halfLongEdge, y0 + median]; + break; + } + + if (active) { + self.ctx.strokeStyle = self.style.unhideIndicatorBorderColor; + self.ctx.lineWidth = 2; + drawLines(borderCoords); + + self.ctx.fillStyle = self.style.unhideIndicatorBackgroundColor; + let offset = dir === 'r' || dir === 'b' ? 1 : 0; + if (dir === 'l' || dir === 'r') + fillRect(x - offset, y, size + offset, size); + else fillRect(x, y - offset, size, size + offset); + } + + self.ctx.fillStyle = self.style.unhideIndicatorColor; + drawLines(coords, true); } function drawOrderByArrow(x, y) { var mt = self.style.columnHeaderOrderByArrowMarginTop * self.scale, @@ -719,7 +780,15 @@ export default function (self) { columnGroupsRectInfo = {}, collapsedColumnGroups = self.getCollapsedColumnGroups(), collapsedRowGroups = self.getCollapsedRowGroups(), - cellHeight = self.style.cellHeight; + cellHeight = self.style.cellHeight, + currentRowIndexOffset = 0, + /** @type {Array<{from:number,plus:number}>} */ + rowIndexOffsetByHiddenRows = self.hiddenRowRanges + .map((range) => ({ + from: range[0], + plus: range[1] - range[0] + 1, + })) + .sort((a, b) => a.from - b.from); drawCount += 1; p = performance.now(); self.visibleRowHeights = []; @@ -1369,6 +1438,19 @@ export default function (self) { group.to === cell.boundRowIndex, ) >= 0; } + // We don't treat the row index difference from hidden rows as the row gap. + if (hasRowGap && self.hiddenRowRanges.length > 0) { + for (let i = 0; i < self.hiddenRowRanges.length; i++) { + const [beginRowIndex, endRowIndex] = self.hiddenRowRanges[i]; + if ( + cell.boundRowIndex === endRowIndex + 1 && + previousRowNumber === beginRowIndex - 1 + ) { + hasRowGap = false; + break; + } + } + } if (hasRowGap) { const barHeight = self.style.rowHeaderCellRowNumberGapHeight; @@ -1384,9 +1466,141 @@ export default function (self) { } } + //#region draw unhide indicator for column headers + if (isColumnHeader && self.attributes.showUnhideColumnsIndicator) { + const hovered = self.hovers.unhideIndicator; + const size = self.style.unhideIndicatorSize; + const cellX = x; + const topY = cell.y + Math.max(0.5 * (cell.height - size), 0); + + const isActive = (orderIndex) => + hovered && + (hovered.dir === 'l' || hovered.dir === 'r') && + orderIndex >= hovered.orderIndex0 && + orderIndex <= hovered.orderIndex1; + const isHiddenColumn = (columnIndex) => + columnIndex >= 0 && + schema[columnIndex] && + schema[columnIndex].hidden; + + let orderIndex0, orderIndex1; + const drawIndicator = (leftX, dir, active) => { + self.visibleUnhideIndicators.push({ + x: leftX - 1, + y: topY - 1, + x2: leftX + size + 2, + y2: topY + size + 2, + orderIndex0, + orderIndex1, + dir, + }); + if (!active) { + const line = cell.text && cell.text.lines && cell.text.lines[0]; + if (line) { + const iconsWidth = orderByArrowSize + treeArrowSize; + const lineX0 = iconsWidth > 0 ? iconsWidth : line.x; + const lineX1 = line.x + line.width; + if (leftX + size >= lineX0 && leftX <= lineX1) return; + } + } + drawUnhideIndicator(leftX, topY, size, dir, active); + }; // end of drawIndicator + + let orderIndexPtr = columnOrderIndex - 1; + const prevColumnIndex = self.orders.columns[orderIndexPtr]; + if (isHiddenColumn(prevColumnIndex)) { + const active = isActive(prevColumnIndex); + orderIndex0 = orderIndexPtr; + orderIndex1 = orderIndexPtr; + while (--orderIndexPtr >= 0) { + if (isHiddenColumn(self.orders.columns[orderIndexPtr])) + orderIndex0 = orderIndexPtr; + else break; + } + drawIndicator(cellX, 'r', active); + } + + orderIndexPtr = columnOrderIndex + 1; + const nextColumnIndex = self.orders.columns[orderIndexPtr]; + if (isHiddenColumn(nextColumnIndex)) { + const active = isActive(nextColumnIndex); + orderIndex0 = orderIndexPtr; + orderIndex1 = orderIndexPtr; + while (++orderIndexPtr < self.orders.columns.length) { + if (isHiddenColumn(self.orders.columns[orderIndexPtr])) + orderIndex1 = orderIndexPtr; + else break; + } + const indicatorX = x + cell.width - size; + drawIndicator(indicatorX, 'l', active); + } + } + //#endregion draw unhide indicator for column headers + + //#region draw unhide indicator for row headers + if ( + isRowHeader && + self.attributes.showUnhideRowsIndicator && + self.hiddenRowRanges.length > 0 + ) { + // Leo's comment: + // from the first row to the last row, `rowIndex` is from 0 to the count of rows + // but `boundRowIndex` can be disordered if there are any ordered columns or filtered columns + // Like this statement: + // console.log(rowIndex, cell.boundRowIndex, cell.formattedValue); + // can output the result like this: + // 0 1 '2' + // 1 3 '4' + const hovered = self.hovers.unhideIndicator; + const size = self.style.unhideIndicatorSize; + const leftX = cell.x + cell.width - size - 2; + const cellY = y; + const topIndicators = {}; + const bottomIndicators = {}; + self.hiddenRowRanges.forEach((it) => { + topIndicators[it[0] - 1] = it; + bottomIndicators[it[1] + 1] = it; + }); + + const rowIndex = cell.rowIndex + currentRowIndexOffset; + const isActive = () => + hovered && + (hovered.dir === 't' || hovered.dir === 'b') && + rowIndex >= hovered.orderIndex0 - 1 && + rowIndex <= hovered.orderIndex1 + 1; + + let orderIndex0, orderIndex1; + const drawIndicator = (topY, dir, active) => { + self.visibleUnhideIndicators.push({ + x: leftX - 1, + y: topY - 1, + x2: leftX + size + 2, + y2: topY + size + 2, + orderIndex0, + orderIndex1, + dir, + }); + drawUnhideIndicator(leftX, topY, size, dir, active); + }; // end of drawIndicator + + let matched = topIndicators[rowIndex]; + if (matched) { + const indicatorY = cellY + cell.height - size; + [orderIndex0, orderIndex1] = matched; + drawIndicator(indicatorY, 't', isActive()); + } + matched = bottomIndicators[rowIndex]; + if (matched) { + const indicatorY = cellY; + [orderIndex0, orderIndex1] = matched; + drawIndicator(indicatorY, 'b', isActive()); + } + } + //#endregion draw unhide indicator for row headers + x += cell.width + (bc ? 0 : self.style.cellBorderWidth); return cell.width; - }; + }; // end of drawEach } function drawFilterButton(cell, ev) { var posX = cell.x + cell.width - self.style.filterButtonWidth - 1; @@ -1424,17 +1638,20 @@ export default function (self) { // as it is in the unfiltered data, instead of simply the viewed // row index + 1. If rowIndex > viewData.length, it's a new row // added to the end, and we want to render that new row's number - const filteredRowNumber = - self.viewData && rowIndex < self.viewData.length - ? self.getBoundRowIndexFromViewRowIndex(rowIndex) + 1 - : self.originalData + let filteredRowNumber; + if (self.viewData && rowIndex < self.viewData.length) + filteredRowNumber = + self.getBoundRowIndexFromViewRowIndex(rowIndex) + 1; + else + filteredRowNumber = self.originalData ? self.originalData.length + 1 : rowOrderIndex + 1; - const rowHeaderValue = + let rowHeaderValue = self.hasActiveFilters() || self.hasCollapsedRowGroup() ? filteredRowNumber : rowIndex + 1; + rowHeaderValue += currentRowIndexOffset; const rowHeaderCell = { rowHeaderCell: rowHeaderValue }; const headerDescription = { @@ -1451,6 +1668,14 @@ export default function (self) { -1, -1, ); + + if ( + rowIndexOffsetByHiddenRows[0] && + rowHeaderValue >= rowIndexOffsetByHiddenRows[0].from + ) { + const { plus } = rowIndexOffsetByHiddenRows.shift(); + currentRowIndexOffset += plus; + } } } function drawHeaders() { @@ -1675,12 +1900,20 @@ export default function (self) { schema = self.getSchema(); self.visibleCells = []; self.visibleGroups = []; + self.visibleUnhideIndicators = []; self.canvasOffsetTop = self.isChildGrid ? self.parentNode.offsetTop : 0.5; self.canvasOffsetLeft = self.isChildGrid ? self.parentNode.offsetLeft : -0.5; h = self.height; w = self.width; + + // patch for first row being hidden + const firstRowIndexOffset = rowIndexOffsetByHiddenRows[0]; + if (firstRowIndexOffset && firstRowIndexOffset.from === 0) { + currentRowIndexOffset = firstRowIndexOffset.plus; + rowIndexOffsetByHiddenRows.shift(); + } } function drawBackground() { radiusRect(0, 0, w, h, 0); @@ -1876,7 +2109,7 @@ export default function (self) { leftX -= toggleHandlePadding + 1; if (containsEnd) rightX += toggleHandleSize; } - strokeLines(lineCoords); + drawLines(lineCoords); ctx.restore(); pushToVisibleGroups(leftX, rightX); } @@ -2054,7 +2287,7 @@ export default function (self) { // add more clickable area into `visibleGroups` if (containsEnd) bottomY += toggleHandleSize; } - strokeLines(lineCoords); + drawLines(lineCoords); ctx.restore(); pushToVisibleGroups(topY, bottomY); } diff --git a/lib/events/index.js b/lib/events/index.js index db9c7c5c..04c62422 100644 --- a/lib/events/index.js +++ b/lib/events/index.js @@ -74,6 +74,43 @@ export default function (self) { 1), ); }; + /** + * @returns {number} dataWidth + */ + self.refreshScrollCacheX = function () { + const s = self.getSchema(); + self.scrollCache.x = []; + + /** @type {number} it will be used in `reduceSchema` only */ + let frozenWidth = 0; + + const collapsedColumnGroups = self.getCollapsedColumnGroups(); + const isColumnCollapsed = (columnIndex) => + collapsedColumnGroups.findIndex( + (group) => columnIndex >= group.from && columnIndex <= group.to, + ) >= 0; + + const dataWidth = + s.reduce(function reduceSchema(accumulator, column, columnIndex) { + // intentional redefintion of column. This causes scrollCache to be in the correct order + const schemaIndex = self.orders.columns[columnIndex]; + const columnWidth = self.getColumnWidth(schemaIndex); + column = s[schemaIndex]; + if (!column.hidden && !isColumnCollapsed(columnIndex)) + accumulator += columnWidth; + if (columnIndex < self.frozenColumn) { + self.scrollCache.x[columnIndex] = accumulator; + frozenWidth = accumulator; + } else { + self.scrollCache.x[columnIndex] = Math.max( + frozenWidth + columnWidth, + accumulator, + ); + } + return accumulator; + }, 0) || 0; + return dataWidth; + }; self.resize = function (drawAfterResize) { if (!self.canvas) { return; @@ -132,8 +169,7 @@ export default function (self) { rowHeaderCellWidth = self.getRowHeaderCellWidth(), topGroupAreaHeight = self.getColumnGroupAreaHeight(), leftGroupAreaWidth = self.getRowGroupAreaWidth(), - ch = self.style.cellHeight, - s = self.getSchema(); + ch = self.style.cellHeight; // sets actual DOM canvas element function checkScrollBoxVisibility() { self.scrollBox.horizontalBarVisible = @@ -187,7 +223,7 @@ export default function (self) { } }); } - self.scrollCache.x = []; + dataWidth = self.refreshScrollCacheX(); self.scrollCache.y = []; for (x = 0; x < l; x += 1) { self.scrollCache.y[x] = dataHeight; @@ -199,32 +235,6 @@ export default function (self) { if (l > 1) { self.scrollCache.y[x] = dataHeight; } - const collapsedColumnGroups = self.getCollapsedColumnGroups(); - const isColumnCollapsed = (columnIndex) => - collapsedColumnGroups.findIndex( - (group) => columnIndex >= group.from && columnIndex <= group.to, - ) >= 0; - /** @type {number} it will be used in `reduceSchema` only */ - let frozenWidth = 0; - dataWidth = - s.reduce(function reduceSchema(accumulator, column, columnIndex) { - // intentional redefintion of column. This causes scrollCache to be in the correct order - const schemaIndex = self.orders.columns[columnIndex]; - const columnWidth = self.getColumnWidth(schemaIndex); - column = s[schemaIndex]; - if (!column.hidden && !isColumnCollapsed(columnIndex)) - accumulator += columnWidth; - if (columnIndex < self.frozenColumn) { - self.scrollCache.x[columnIndex] = accumulator; - frozenWidth = accumulator; - } else { - self.scrollCache.x[columnIndex] = Math.max( - frozenWidth + columnWidth, - accumulator, - ); - } - return accumulator; - }, 0) || 0; if (self.attributes.showNewRow) { dataHeight += ch; } @@ -501,6 +511,20 @@ export default function (self) { } self.currentCell = cell; + if ( + !self.draggingItem && // It is not in dragging mode (avoid changing hovers frequent) + cell && + (cell.context === 'cell' || cell.context === self.cursorGrab) + ) { + const indicator = self.getUnhideIndicator(self.mouse.x, self.mouse.y); + if (indicator) { + self.cursor = 'pointer'; + self.hovers = { unhideIndicator: indicator }; + self.draw(); + return; + } + } + self.hovers = {}; if (!self.draggingItem && cell) { self.dragItem = cell; @@ -791,6 +815,15 @@ export default function (self) { return; } + const unhideIndicator = self.getUnhideIndicator(pos.x, pos.y); + if (unhideIndicator) { + const { dir, orderIndex0, orderIndex1 } = unhideIndicator; + if (dir === 'l' || dir === 'r') + self.unhideColumns(orderIndex0, orderIndex1); + else self.unhideRows(orderIndex0, orderIndex1); + return; + } + let group = self.getColumnGroupAt(pos.x, pos.y); if (!group) group = self.getRowGroupAt(pos.x, pos.y); if (group) { @@ -1294,13 +1327,14 @@ export default function (self) { self.currentCell.columnIndex !== undefined && self.dragMode === 'frozen-column-marker' ) { + const dataWidth = self.refreshScrollCacheX(); self.scrollBox.scrollLeft = 0; self.frozenColumn = self.currentCell.columnIndex + 1; self.scrollBox.bar.h.x = rowHeaderCellWidth + self.scrollCache.x[self.frozenColumn - 1]; self.scrollBox.box.h.x = rowHeaderCellWidth + self.scrollCache.x[self.frozenColumn - 1]; - var dataWidth = self.scrollCache.x[self.scrollCache.x.length - 1]; + // var dataWidth = self.scrollCache.x[self.scrollCache.x.length - 1]; self.scrollBox.widthBoxRatio = (self.scrollBox.width - self.scrollCache.x[self.frozenColumn - 1]) / dataWidth; @@ -1359,6 +1393,7 @@ export default function (self) { move = /-move/.test(self.dragMode), freeze = /frozen-row-marker|frozen-column-marker/.test(self.dragMode), resize = /-resize/.test(self.dragMode); + const onUnhideIndicator = self.hovers && self.hovers.unhideIndicator; self.dragStart = overridePos || self.getLayerPos(e); self.scrollStart = { left: self.scrollBox.scrollLeft, @@ -1388,7 +1423,10 @@ export default function (self) { if (self.dragStartObject.isGrid) { return; } - if (self.scrollModes.indexOf(self.dragStartObject.context) !== -1) { + if ( + self.scrollModes.indexOf(self.dragStartObject.context) !== -1 && + !onUnhideIndicator + ) { self.scrollMode = self.dragStartObject.context; self.scrollStartMode = self.dragStartObject.context; self.scrollGrid(e); @@ -1457,7 +1495,7 @@ export default function (self) { document.body.addEventListener('mouseup', self.stopFreezeMove, false); return self.mousemove(e); } - if (resize) { + if (resize && !onUnhideIndicator) { self.draggingItem = self.dragItem; if (self.draggingItem.rowOpen) { self.resizingStartingHeight = diff --git a/lib/groups/util.js b/lib/groups/util.js new file mode 100644 index 00000000..2e08da0e --- /dev/null +++ b/lib/groups/util.js @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Merge a new hidden row range into existed ranges array + * @param {any[]} hiddenRowRanges tuples: Array<[bgeinRowIndex, endRowIndex]> + * @param {number[]} newRange tuple: [beginRowIndex, endRowIndex] + * @returns {boolean} + */ +const mergeHiddenRowRanges = function (hiddenRowRanges, newRange) { + const [beginRowIndex, endRowIndex] = newRange; + if (endRowIndex < beginRowIndex) return false; + let inserted = false; + for (let i = 0; i < hiddenRowRanges.length; i++) { + const range = hiddenRowRanges[i]; + if (beginRowIndex > range[1] + 1) continue; + if (beginRowIndex <= range[0] && endRowIndex >= range[0]) { + hiddenRowRanges[i] = [beginRowIndex, Math.max(endRowIndex, range[1])]; + inserted = true; + break; + } + if (beginRowIndex >= range[0]) { + hiddenRowRanges[i] = [range[0], Math.max(endRowIndex, range[1])]; + inserted = true; + break; + } + } + if (!inserted) hiddenRowRanges.push([beginRowIndex, endRowIndex]); + // merge intersections after sorting ranges + hiddenRowRanges.sort((a, b) => a[0] - b[0]); + for (let i = 0; i < hiddenRowRanges.length - 1; i++) { + const range = hiddenRowRanges[i]; + const nextRange = hiddenRowRanges[i + 1]; + if (nextRange[0] <= range[1] + 1) { + hiddenRowRanges[i] = [range[0], Math.max(range[1], nextRange[1])]; + hiddenRowRanges.splice(i + 1, 1); + i--; + } + } + return true; +}; + +export { mergeHiddenRowRanges }; diff --git a/lib/intf.js b/lib/intf.js index 0778d24f..832f6753 100644 --- a/lib/intf.js +++ b/lib/intf.js @@ -57,6 +57,19 @@ export default function (self, ctor) { self.scrollBox = {}; self.visibleRows = []; self.visibleCells = []; + /** + * Each item of this array contains these properties: + * - `x`, `y`, `x2`, `y2` + * - `orderIndex0`, `orderIndex1`: The closed interval of the hiding rows/columns. + * - `dir`: The directon of the unhide indicator. 'l' and 'r' for columns, 't' and 'b' for rows + */ + self.visibleUnhideIndicators = []; + /** + * Each item is a tuple conatins two numbers: + * its type difination: Array<[beginRowIndex, endRowIndex]> + * Each tuple represents a closed Interval + */ + self.hiddenRowRanges = []; /** * This array stored all groups information with context for drawing, * it is generated by drawing functions, @@ -654,6 +667,16 @@ export default function (self, ctor) { originalRowIndex, ]); + // Remove hidden rows here. So we can keep the bound indexes correct + if (self.hiddenRowRanges.length > 0) { + const ranges = self.hiddenRowRanges.sort((a, b) => b[1] - a[1]); + for (let i = 0; i < ranges.length; i++) { + const [beginRowIndex, endRowIndex] = ranges[i]; + const countOfRows = endRowIndex - beginRowIndex + 1; + newViewData.splice(beginRowIndex, countOfRows); + } + } + // Apply filtering for (const [headerName, filterText] of Object.entries(self.columnFilters)) { const header = self.getHeaderByName(headerName); @@ -1257,6 +1280,17 @@ export default function (self, ctor) { }; }; + self.unhideColumns = function (orderIndex0, orderIndex1) { + const orders = self.orders.columns; + const schema = self.getSchema(); + for (let i = orderIndex0; i <= orderIndex1; i++) { + const columnIndex = orders[i]; + const s = schema[columnIndex]; + if (s && s.hidden) s.hidden = false; + } + self.refresh(); + }; + self.getDomRoot = function () { return self.shadowRoot ? self.shadowRoot.host : self.parentNode; }; @@ -1487,6 +1521,9 @@ export default function (self, ctor) { self.intf.cut = self.cut; self.intf.paste = self.paste; self.intf.setStyleProperty = self.setStyleProperty; + self.intf.hideColumns = self.hideColumns; + self.intf.hideRows = self.hideRows; + self.intf.unhideRows = self.unhideRows; Object.defineProperty(self.intf, 'defaults', { get: function () { return { diff --git a/lib/publicMethods.js b/lib/publicMethods.js index 76a5fb46..fd236459 100644 --- a/lib/publicMethods.js +++ b/lib/publicMethods.js @@ -2,6 +2,8 @@ /*globals define: true, MutationObserver: false, requestAnimationFrame: false, performance: false, btoa: false*/ 'use strict'; +import { mergeHiddenRowRanges } from './groups/util'; + export default function (self) { /** * Converts a integer into a letter A - ZZZZZ... @@ -762,6 +764,63 @@ export default function (self) { } } }; + /** + * Hide column/columns + * @memberof canvasDatagrid + * @name hideColumns + * @param {number} beginColumnOrderIndex + * @param {number} [endColumnOrderIndex] + */ + self.hideColumns = function (beginColumnOrderIndex, endColumnOrderIndex) { + const schema = self.getSchema(); + const orders = self.orders.columns; + let count = 0; + if (typeof endColumnOrderIndex !== 'number') + endColumnOrderIndex = beginColumnOrderIndex; + for ( + let orderIndex = beginColumnOrderIndex; + orderIndex <= endColumnOrderIndex; + orderIndex++ + ) { + const columnIndex = orders[orderIndex]; + if (columnIndex >= 0 && !schema[columnIndex].hidden) { + count++; + schema[columnIndex].hidden = true; + } + } + if (count > 0) { + self.setStorageData(); + setTimeout(function () { + self.resize(true); + }, 10); + } + }; + /** + * Hide rows + * @memberof canvasDatagrid + * @name hideRows + * @param {number} beginRowIndex + * @param {number} endRowIndex + */ + self.hideRows = function (beginRowIndex, endRowIndex) { + if ( + mergeHiddenRowRanges(self.hiddenRowRanges, [beginRowIndex, endRowIndex]) + ) + self.refresh(); + }; + /** + * Unhide rows + * @memberof canvasDatagrid + * @name unhideRows + * @param {number} beginRowIndex + * @param {number} endRowIndex + */ + self.unhideRows = function (beginRowIndex, endRowIndex) { + self.hiddenRowRanges = self.hiddenRowRanges.filter( + (range) => range[0] !== beginRowIndex || range[1] !== endRowIndex, + ); + self.refresh(); + }; /** * Resizes a column to fit the longest value in the column. Call without a value to resize all columns. * Warning, can be slow on very large record sets (1m records ~3-5 seconds on an i7). @@ -1336,6 +1395,28 @@ export default function (self) { return c.columnIndex === x && c.rowIndex === y; })[0]; }; + /** + * Get an unhide indicator at grid pixel coordinate x and y. + * @memberof canvasDatagrid + * @name getUnhideIndicator + * @method + * @param {number} x Number of pixels from the left. + * @param {number} y Number of pixels from the top. + */ + self.getUnhideIndicator = function (x, y) { + const indicators = self.visibleUnhideIndicators; + if (indicators.length <= 0) return; + for (let i = 0; i < indicators.length; i++) { + const indicator = indicators[i]; + if ( + x >= indicator.x && + y >= indicator.y && + x <= indicator.x2 && + y <= indicator.y2 + ) + return indicator; + } + }; /** * Get a column group at grid pixel coordinate x and y. * @memberof canvasDatagrid diff --git a/test/index.js b/test/index.js index 5f30e106..6184e7af 100644 --- a/test/index.js +++ b/test/index.js @@ -24,6 +24,7 @@ import publicInterfaceTests from './public-interface.js'; import contextMenuTests from './context-menu.js'; import webComponentTests from './web-component.js'; import scrollingTests from './scrolling.js'; +import unhideIndicatorTests from './unhide-indicator.js'; import unitTests from './unit/index.js'; @@ -56,6 +57,7 @@ describe('canvas-datagrid', function () { describe('Filters', filtersTests); describe('Attributes', attributesTests); describe('Groups', groupsTests); + describe('Unhide indicator', unhideIndicatorTests); describe('Reorder columns', reorderColumnsTests); }); describe('Unit Tests', unitTests); diff --git a/test/unhide-indicator.js b/test/unhide-indicator.js new file mode 100644 index 00000000..6debbe2c --- /dev/null +++ b/test/unhide-indicator.js @@ -0,0 +1,178 @@ +import { + click, + assertPxColorFn, + g, + mousemove, + shouldContainCell, + shouldNotContainCell, + delay, + c, + makeData, + itoa, + savePartOfCanvasToString, +} from './util.js'; + +export default function () { + const getData = () => + makeData(10, 10, (y, x) => [itoa(x).toUpperCase(), y + 1].join('')); + const cellHeight = 25; + const cellHalfHeight = 12; + const rowHeaderWidth = 45; + const setupStyles = (grid) => { + grid.style.unhideIndicatorColor = c.y; + grid.style.unhideIndicatorBackgroundColor = c.r; + grid.style.rowHeaderCellBackgroundColor = c.white; + grid.style.columnHeaderCellBackgroundColor = c.white; + grid.style.activeRowHeaderCellBackgroundColor = c.white; + grid.style.activeColumnHeaderCellBackgroundColor = c.white; + }; + + it('Should contain methods to hide columns', async function () { + const grid = g({ test: this.test, data: getData() }); + shouldContainCell(grid, 'A1'); + + grid.hideColumns(0, 1); + await delay(15); + shouldNotContainCell(grid, 'A1'); + shouldNotContainCell(grid, 'B1'); + shouldContainCell(grid, 'C1'); + }); + + it('Should contain methods to hide rows', async function () { + const grid = g({ test: this.test, data: getData() }); + shouldContainCell(grid, 'A1'); + + grid.hideRows(0, 2); + await delay(15); + shouldNotContainCell(grid, 'A1'); + shouldNotContainCell(grid, 'A2'); + shouldNotContainCell(grid, 'A3'); + + shouldContainCell(grid, 'A4'); + }); + + it('Should not show unhide indicators by default', async function () { + const grid = g({ test: this.test, data: getData() }); + setupStyles(grid); + grid.style.columnHeaderCellHorizontalAlignment = 'center'; + grid.hideColumns(0, 1); + grid.hideRows(0, 2); + await delay(15); + + await assertColor(grid, rowHeaderWidth + 6, cellHalfHeight, c.white); // on column header + }); + + it('Should show unhide indicators for columns if `showUnhideColumnsIndicator` is true', async function () { + const grid = g({ + test: this.test, + data: getData(), + showUnhideColumnsIndicator: true, + }); + setupStyles(grid); + grid.style.columnHeaderCellHorizontalAlignment = 'center'; + grid.hideColumns(0, 1); + grid.hideRows(0, 2); + await delay(15); + + await assertColor(grid, rowHeaderWidth + 6, cellHalfHeight, c.y); // on column header + await assertColor(grid, rowHeaderWidth - 8, cellHeight + 8, c.white); // on row header + }); + + it('Should show unhide indicators for rows if `showUnhideRowsIndicator` is true', async function () { + const grid = g({ + test: this.test, + data: getData(), + showUnhideRowsIndicator: true, + }); + setupStyles(grid); + grid.style.columnHeaderCellHorizontalAlignment = 'center'; + grid.hideColumns(0, 1); + grid.hideRows(0, 2); + await delay(15); + + await assertColor(grid, rowHeaderWidth + 6, cellHalfHeight, c.white); // on column header + await assertColor(grid, rowHeaderWidth - 9, cellHeight + 7, c.y); // on row header + }); + + it('Should display unhide indicators in active mode when the cursor hovers', async function () { + const grid = g({ + test: this.test, + data: getData(), + showUnhideColumnsIndicator: true, + showUnhideRowsIndicator: true, + }); + setupStyles(grid); + grid.style.columnHeaderCellHorizontalAlignment = 'center'; + grid.hideColumns(0, 1); + grid.hideRows(0, 2); + + let x, y; + + x = rowHeaderWidth + 6; + y = cellHalfHeight; + mousemove(document.body, x, y, grid.canvas); + await assertColor(grid, x, y, c.y); + await assertColor(grid, x - 6, y, c.r); + await assertColor(grid, x + 6, y, c.r); + + x = rowHeaderWidth - 9; + y = cellHeight + 7; + mousemove(document.body, x, y, grid.canvas); + await assertColor(grid, x, y, c.y); + await assertColor(grid, x, y - 6, c.r); + await assertColor(grid, x, y + 6, c.r); + }); + + it('Should unhide columns after the indicator is clicked', async function () { + const grid = g({ + test: this.test, + data: getData(), + showUnhideColumnsIndicator: true, + showUnhideRowsIndicator: true, + }); + setupStyles(grid); + grid.style.columnHeaderCellHorizontalAlignment = 'center'; + grid.hideColumns(0, 1); + grid.hideRows(0, 2); + + let x, y; + x = rowHeaderWidth + 6; + y = cellHalfHeight; + click(grid.canvas, x, y); + shouldNotContainCell(grid, 'A1'); + shouldContainCell(grid, 'A4'); + + x = rowHeaderWidth - 8; + y = cellHeight + 8; + click(grid.canvas, x, y); + shouldContainCell(grid, 'A1'); + }); + + function assertColor(grid, x, y, color, debugContext = false) { + return new Promise((resolve, reject) => { + assertPxColorFn( + grid, + x, + y, + color, + false, + )((err) => { + if (err) { + printContext(); + return reject(err); + } + if (debugContext) printContext(); + return setTimeout(resolve, 15); + }); + }); + function printContext() { + x--; + y--; + const w = 51; + const h = 51; + const dpr = ` DPR=${window.devicePixelRatio}`; + console.error(`Color context at [${[x, y, w, h].join(', ')}]${dpr}`); + console.error(savePartOfCanvasToString(grid, x, y, w, h)); + } + } +} diff --git a/test/unit/index.js b/test/unit/index.js index 21251639..c26c54c2 100644 --- a/test/unit/index.js +++ b/test/unit/index.js @@ -1,7 +1,9 @@ 'use strict'; import parseClipboardData from './parse-clip-board-data.js'; +import mergeHiddenRowRanges from './merge-hidden-row-ranges.js'; export default function () { describe('clipboard', parseClipboardData); + describe('mergeHiddenRowRanges', mergeHiddenRowRanges); } diff --git a/test/unit/merge-hidden-row-ranges.js b/test/unit/merge-hidden-row-ranges.js new file mode 100644 index 00000000..f3c440a4 --- /dev/null +++ b/test/unit/merge-hidden-row-ranges.js @@ -0,0 +1,92 @@ +'use strict'; + +import { mergeHiddenRowRanges } from '../../lib/groups/util.js'; + +export default function () { + const { deepStrictEqual } = chai.assert; + it('invalid ranges can not be merged', function () { + const ranges = []; + deepStrictEqual(mergeHiddenRowRanges(ranges, [10, 5]), false); + deepStrictEqual(ranges, []); + }); + + it('merge range only contains one row', function () { + const ranges = []; + deepStrictEqual(mergeHiddenRowRanges(ranges, [1, 1]), true); + deepStrictEqual(ranges, [[1, 1]]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [2, 2]), true); + deepStrictEqual(ranges, [[1, 2]]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [5, 5]), true); + deepStrictEqual(ranges, [ + [1, 2], + [5, 5], + ]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [1, 5]), true); + deepStrictEqual(ranges, [[1, 5]]); + }); + + it('merge ranges', function () { + const ranges = []; + deepStrictEqual(mergeHiddenRowRanges(ranges, [5, 10]), true); + deepStrictEqual(ranges, [[5, 10]]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [5, 11]), true); + deepStrictEqual(ranges, [[5, 11]]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [12, 15]), true); + deepStrictEqual(ranges, [[5, 15]]); + }); + + it('merge independent intervals', function () { + const ranges = [[5, 15]]; + deepStrictEqual(mergeHiddenRowRanges(ranges, [20, 30]), true); + deepStrictEqual(ranges, [ + [5, 15], + [20, 30], + ]); + + deepStrictEqual(mergeHiddenRowRanges(ranges, [1, 3]), true); + deepStrictEqual(ranges, [ + [1, 3], + [5, 15], + [20, 30], + ]); + }); + + it('new range spans two existed ranges', function () { + let ranges = [ + [1, 3], + [5, 15], + [20, 30], + ]; + deepStrictEqual(mergeHiddenRowRanges(ranges, [7, 25]), true); + deepStrictEqual(ranges, [ + [1, 3], + [5, 30], + ]); + + ranges = [ + [1, 3], + [5, 15], + [20, 30], + ]; + deepStrictEqual(mergeHiddenRowRanges(ranges, [7, 35]), true); + deepStrictEqual(ranges, [ + [1, 3], + [5, 35], + ]); + }); + + it('new range wraps all existed ranges', function () { + const ranges = [ + [1, 3], + [5, 15], + [20, 30], + ]; + deepStrictEqual(mergeHiddenRowRanges(ranges, [1, 50]), true); + deepStrictEqual(ranges, [[1, 50]]); + }); +} diff --git a/test/util.js b/test/util.js index cafd5b1f..f0d1b6c4 100644 --- a/test/util.js +++ b/test/util.js @@ -89,6 +89,34 @@ export function cleanup(done) { done(); } +/** + * Find the cell from the grid by text + */ +export function findCell(grid, text) { + return grid.visibleCells.find( + (it) => + (it.style === 'cell' || it.style === 'activeCell') && + it.formattedValue === text, + ); +} + +export function shouldContainCell(grid, text) { + const cell = findCell(grid, text); + doAssert(cell, `the grid should contain cell with text "${text}"`); +} + +export function shouldNotContainCell(grid, text) { + const cell = findCell(grid, text); + doAssert(!cell, `the grid should not contain cell with text "${text}"`); +} + +/** + * Delay implemented by Promise + */ +export function delay(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + // Draws a 'crosshairs' marker at coordinates (x,y). // The marker includes: // - A 1px vertical line at x