diff --git a/packages/core/API.md b/packages/core/API.md index 18295976f..c2439c17f 100644 --- a/packages/core/API.md +++ b/packages/core/API.md @@ -13,14 +13,9 @@ or you can create a portal element yourself using the `createPortal` function fr ```jsx const portalRef = useRef(null); <> - { - createPortal( -
, - document.body - ) - } - - + {createPortal(
, document.body)} + +; ``` Once you've got that done, the easiest way to use the Data Grid is to give it a fixed size: @@ -87,10 +82,10 @@ All data grids must set these props. These props are the bare minimum required t Most data grids will want to set the majority of these props one way or another. | Name | Description | -|---------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [fixedShadowX](#fixedshadow) | Enable/disable a shadow behind fixed columns on the X axis. | | [fixedShadowY](#fixedshadow) | Enable/disable a shadow behind the header(s) on the Y axis. | -| [freezeColumns](#freezecolumns) | The number of columns which should remain in place when scrolling horizontally. The row marker column, if enabled is always frozen and is not included in this count. | +| [freezeColumns](#freezecolumns) | The number of columns which should remain in place when scrolling horizontally, or a tuple `[left, right]` to freeze columns on both sides. The row marker column, if enabled is always frozen and is not included in this count. | | [getCellsForSelection](#getcellsforselection) | Used to fetch large amounts of cells at once. Used for copy/paste, if unset copy will not work. | | [markdownDivCreateNode](#markdowndivcreatenode) | If specified, it will be used to render Markdown, instead of the default Markdown renderer used by the Grid. You'll want to use this if you need to process your Markdown for security purposes, or if you want to use a renderer with different Markdown features. | | [onVisibleRegionChanged](#onvisibleregionchanged) | Emits whenever the visible rows/columns changes. | @@ -192,7 +187,7 @@ Most data grids will want to set the majority of these props one way or another. | [onRowMoved](#onrowmoved) | Emitted when a row has been dragged to a new location. | | [preventDiagonalScrolling](#preventdiagonalscrolling) | Prevents diagonal scrolling | | [rowSelectionMode](#rowselectionmode) | Determines if row selection requires a modifier key to enable multi-selection or not. | -| [columnSelectionMode](#columnselectionmode) | Determines if column selection requires a modifier key to enable multi-selection or not. | +| [columnSelectionMode](#columnselectionmode) | Determines if column selection requires a modifier key to enable multi-selection or not. | | [scrollToEnd](#scrolltoend) | When set to true, the grid will scroll to the end. The ref has a better method to do this and this prop should not be used but it will remain supported for the foreseeable future. | | [showMinimap](#showminimap) | Shows the interactive minimap of the grid. | | [validateCell](#validatecell) | When returns false indicates to the user the value will not be accepted. When returns a new GridCell the value is coerced to match. | @@ -550,11 +545,18 @@ getCellContent: (cell: Item) => GridCell; ## freezeColumns ```ts -freezeColumns?: number; +freezeColumns?: number | readonly [left: number, right: number]; ``` Set to a positive number to freeze columns on the left side of the grid during horizontal scrolling. +Alternatively, pass a tuple `[left, right]` where: + +- `left` is the number of columns to freeze on the left side +- `right` is the number of columns to freeze on the right side + +Note: The row marker column, if enabled, is always frozen and is not included in the left freeze count. + --- ## getCellsForSelection diff --git a/packages/core/src/common/render-state-provider.ts b/packages/core/src/common/render-state-provider.ts index 55e54b4f1..3366e34a6 100644 --- a/packages/core/src/common/render-state-provider.ts +++ b/packages/core/src/common/render-state-provider.ts @@ -35,33 +35,42 @@ export abstract class WindowingTrackerBase { height: 0, }; - public freezeCols: number = 0; + public columnsLength: number = 0; + public freezeCols: number | readonly [number, number] = 0; public freezeRows: number[] = []; protected isInWindow = (packed: number) => { + const freezeColumnsLeft = typeof this.freezeCols === "number" ? this.freezeCols : this.freezeCols[0]; + const freezeColumnsRight = typeof this.freezeCols === "number" ? 0 : this.freezeCols[1]; const col = unpackCol(packed); const row = unpackRow(packed); const w = this.visibleWindow; - const colInWindow = (col >= w.x && col <= w.x + w.width) || col < this.freezeCols; + const colInWindow = + (col >= w.x && col <= w.x + w.width) || + col < freezeColumnsLeft || + col > this.columnsLength - freezeColumnsRight - 1; + const rowInWindow = (row >= w.y && row <= w.y + w.height) || this.freezeRows.includes(row); return colInWindow && rowInWindow; }; protected abstract clearOutOfWindow: () => void; - public setWindow(newWindow: Rectangle, freezeCols: number, freezeRows: number[]): void { + public setWindow(newWindow: Rectangle, freezeCols: number, freezeRows: number[], columnsLength: number): void { if ( this.visibleWindow.x === newWindow.x && this.visibleWindow.y === newWindow.y && this.visibleWindow.width === newWindow.width && this.visibleWindow.height === newWindow.height && this.freezeCols === freezeCols && + this.columnsLength === columnsLength && deepEqual(this.freezeRows, freezeRows) ) return; this.visibleWindow = newWindow; this.freezeCols = freezeCols; this.freezeRows = freezeRows; + this.columnsLength = columnsLength; this.clearOutOfWindow(); } } diff --git a/packages/core/src/common/utils.tsx b/packages/core/src/common/utils.tsx index aad1b77e2..5f4c026de 100644 --- a/packages/core/src/common/utils.tsx +++ b/packages/core/src/common/utils.tsx @@ -282,3 +282,10 @@ export function useDeepMemo(value: T): T { return ref.current; } + +export function normalizeFreezeColumns(freezeColumns: number | readonly [number, number]): readonly [number, number] { + const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + + return [freezeLeftColumns, freezeRightColumns]; +} diff --git a/packages/core/src/data-editor/data-editor-keybindings.ts b/packages/core/src/data-editor/data-editor-keybindings.ts index b68d339cf..3f71dfec6 100644 --- a/packages/core/src/data-editor/data-editor-keybindings.ts +++ b/packages/core/src/data-editor/data-editor-keybindings.ts @@ -67,6 +67,8 @@ export interface ConfigurableKeybinds { readonly selectAll: Keybind; readonly selectRow: Keybind; readonly selectColumn: Keybind; + + readonly contextMenu: Keybind; } export type Keybinds = ConfigurableKeybinds & ForcedKeybinds & Partial; @@ -118,6 +120,7 @@ export const keybindingDefaults: Keybinds = { selectGrowRight: true, selectGrowDown: true, selectGrowLeft: true, + contextMenu: true, }; function realizeKeybind(keybind: Keybind, defaultVal: string): string { @@ -174,6 +177,7 @@ export function realizeKeybinds(keybinds: Keybinds): RealizedKeybinds { selectToLastCell: realizeKeybind(keybinds.selectToLastCell, "primary+shift+End"), selectToLastColumn: realizeKeybind(keybinds.selectToLastColumn, "primary+shift+ArrowRight"), selectToLastRow: realizeKeybind(keybinds.selectToLastRow, "primary+shift+ArrowDown"), + contextMenu: realizeKeybind(keybinds.contextMenu, "shift+F10"), }; } diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 6674c6f7f..9e9ea046b 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -44,7 +44,7 @@ import { mergeAndRealizeTheme, } from "../common/styles.js"; import type { DataGridRef } from "../internal/data-grid/data-grid.js"; -import { getScrollBarWidth, useEventListener, whenDefined } from "../common/utils.js"; +import { getScrollBarWidth, useEventListener, normalizeFreezeColumns, whenDefined } from "../common/utils.js"; import { isGroupEqual, itemsAreEqual, @@ -923,6 +923,8 @@ const DataEditorImpl: React.ForwardRefRenderFunction { if (typeof window === "undefined") return { fontSize: "16px" }; return window.getComputedStyle(document.documentElement); @@ -1573,9 +1575,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction= columns.length - freezeRightColumns; i--) { + frozenRightWidth += columns[i].width; } let trailingRowHeight = 0; const freezeTrailingRowsEffective = freezeTrailingRows + (lastRowSticky ? 1 : 0); @@ -1588,8 +1594,9 @@ const DataEditorImpl: React.ForwardRefRenderFunction= mangledCols.length - freezeRightColumns)) + ) { scrollX = 0; } else if ( dir === "horizontal" || @@ -1664,7 +1675,9 @@ const DataEditorImpl: React.ForwardRefRenderFunction 0) { freezeRegions.push({ x: region.x - rowMarkerOffset, @@ -2616,11 +2640,20 @@ const DataEditorImpl: React.ForwardRefRenderFunction 0) { + if (freezeLeftColumns > 0) { freezeRegions.push({ x: 0, y: rows - freezeTrailingRows, - width: freezeColumns, + width: freezeLeftColumns, + height: freezeTrailingRows, + }); + } + + if (freezeRightColumns > 0) { + freezeRegions.push({ + x: columns.length - freezeRightColumns, + y: rows - freezeTrailingRows, + width: freezeRightColumns, height: freezeTrailingRows, }); } @@ -2635,7 +2668,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { isActivelyDragging.current = false; + isActivelyDraggingHeader.current = false; + setScrollDir(undefined); }, []); const rowGroupingSelectionBehavior = rowGrouping?.selectionBehavior; @@ -3185,6 +3222,42 @@ const DataEditorImpl: React.ForwardRefRenderFunction void) => { + const adjustedCol = args.location[0] - rowMarkerOffset; + if (args.kind === "header") { + onHeaderContextMenu?.(adjustedCol, { ...args, preventDefault }); + } + + if (args.kind === groupHeaderKind) { + if (adjustedCol < 0) { + return; + } + onGroupHeaderContextMenu?.(adjustedCol, { ...args, preventDefault }); + } + + if (args.kind === "cell") { + const [col, row] = args.location; + onCellContextMenu?.([adjustedCol, row], { + ...args, + preventDefault, + }); + + if (!gridSelectionHasItem(gridSelection, args.location)) { + updateSelectedCell(col, row, false, false); + } + } + }, + [ + gridSelection, + onCellContextMenu, + onGroupHeaderContextMenu, + onHeaderContextMenu, + rowMarkerOffset, + updateSelectedCell, + ] + ); + const handleFixedKeybindings = React.useCallback( (event: GridKeyEventArgs): boolean => { const cancel = () => { @@ -3362,6 +3435,35 @@ const DataEditorImpl: React.ForwardRefRenderFunction row || row > vr.y + vr.height || vr.x > col || col > vr.x + vr.width) ) { - return; + // Frozen columns/rows are always rendered regardless of scroll position, so + // being outside the tracked scrollable region doesn't mean they're off-screen. + let isInFreezeArea = false; + if (vr.extras?.freezeRegions !== undefined) { + for (const fr of vr.extras.freezeRegions) { + if (pointInRect(fr, col, row)) { + isInFreezeArea = true; + break; + } + } + } + if (!isInFreezeArea) { + return; + } } const activationEvent: CellActivatedEventArgs = { inputType: "keyboard", @@ -3544,42 +3660,6 @@ const DataEditorImpl: React.ForwardRefRenderFunction void) => { - const adjustedCol = args.location[0] - rowMarkerOffset; - if (args.kind === "header") { - onHeaderContextMenu?.(adjustedCol, { ...args, preventDefault }); - } - - if (args.kind === groupHeaderKind) { - if (adjustedCol < 0) { - return; - } - onGroupHeaderContextMenu?.(adjustedCol, { ...args, preventDefault }); - } - - if (args.kind === "cell") { - const [col, row] = args.location; - onCellContextMenu?.([adjustedCol, row], { - ...args, - preventDefault, - }); - - if (!gridSelectionHasItem(gridSelection, args.location)) { - updateSelectedCell(col, row, false, false); - } - } - }, - [ - gridSelection, - onCellContextMenu, - onGroupHeaderContextMenu, - onHeaderContextMenu, - rowMarkerOffset, - updateSelectedCell, - ] - ); - const onPasteInternal = React.useCallback( async (e?: ClipboardEvent) => { if (!keybindings.paste) return; @@ -3991,7 +4071,12 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + return [ + Math.min(mangledCols.length, freezeLeftColumns + (hasRowMarkers ? 1 : 0)), + Math.min(mangledCols.length, freezeRightColumns) + ] as const; + }, [freezeLeftColumns, freezeRightColumns, hasRowMarkers, mangledCols.length]) React.useImperativeHandle( forwardedRef, diff --git a/packages/core/src/docs/examples/freeze-columns.stories.tsx b/packages/core/src/docs/examples/freeze-columns.stories.tsx index fd24a9e5f..b20ecdd4e 100644 --- a/packages/core/src/docs/examples/freeze-columns.stories.tsx +++ b/packages/core/src/docs/examples/freeze-columns.stories.tsx @@ -30,14 +30,14 @@ export default { ], }; -export const FreezeColumns: React.VFC = (p: { freezeColumns: number }) => { +export const FreezeColumns: React.VFC = (p: { freezeLeftColumns: number, freezeRightColumns: number }) => { const { cols, getCellContent } = useMockDataGenerator(100); return ( = (p: { freezeColumns: number }) => { ); }; (FreezeColumns as any).argTypes = { - freezeColumns: { + freezeLeftColumns: { + control: { + type: "range", + min: 0, + max: 10, + }, + }, + freezeRightColumns: { control: { type: "range", min: 0, @@ -55,5 +62,6 @@ export const FreezeColumns: React.VFC = (p: { freezeColumns: number }) => { }, }; (FreezeColumns as any).args = { - freezeColumns: 1, + freezeLeftColumns: 1, + freezeRightColumns: 1, }; diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index 338a52e10..5f5325e02 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -28,7 +28,13 @@ import { } from "./data-grid-types.js"; import { CellSet } from "./cell-set.js"; import { SpriteManager, type SpriteMap } from "./data-grid-sprites.js"; -import { direction, getScrollBarWidth, useDebouncedMemo, useEventListener } from "../../common/utils.js"; +import { + direction, + getScrollBarWidth, + useDebouncedMemo, + useEventListener, + normalizeFreezeColumns, +} from "../../common/utils.js"; import clamp from "lodash/clamp.js"; import makeRange from "lodash/range.js"; import { drawGrid } from "./render/data-grid-render.js"; @@ -72,7 +78,7 @@ export interface DataGridProps { readonly accessibilityHeight: number; - readonly freezeColumns: number; + readonly freezeColumns: number | readonly [left: number, right: number]; readonly freezeTrailingRows: number; readonly hasAppendRow: boolean; readonly firstColAccessible: boolean; @@ -403,7 +409,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, } = p; const translateX = p.translateX ?? 0; const translateY = p.translateY ?? 0; - const cellXOffset = Math.max(freezeColumns, Math.min(columns.length - 1, cellXOffsetReal)); + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); + const cellXOffset = Math.max(freezeLeftColumns, Math.min(columns.length - 1, cellXOffsetReal)); const ref = React.useRef(null); const windowEventTargetRef = React.useRef(experimental?.eventTarget ?? window); @@ -451,7 +458,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, const mappedColumns = useMappedColumns(columns, freezeColumns); const stickyX = React.useMemo( - () => (fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0), + () => (fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : [0, 0]), [mappedColumns, dragAndDropState, fixedShadowX] ); @@ -526,7 +533,14 @@ const DataGrid: React.ForwardRefRenderFunction = (p, const y = (posY - rect.top) / scale; const edgeDetectionBuffer = 5; - const effectiveCols = getEffectiveColumns(mappedColumns, cellXOffset, width, undefined, translateX); + const effectiveCols = getEffectiveColumns( + mappedColumns, + cellXOffset, + width, + freezeColumns, + undefined, + translateX + ); let button = 0; let buttons = 0; @@ -545,7 +559,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, } // -1 === off right edge - const col = getColumnIndexForX(x, effectiveCols, translateX); + const col = getColumnIndexForX(x, effectiveCols, freezeColumns, width, translateX); // -1: header or above // undefined: offbottom @@ -616,7 +630,11 @@ const DataGrid: React.ForwardRefRenderFunction = (p, let isEdge = bounds !== undefined && bounds.x + bounds.width - posX <= edgeDetectionBuffer; const previousCol = col - 1; - if (posX - bounds.x <= edgeDetectionBuffer && previousCol >= 0) { + if ( + posX - bounds.x <= edgeDetectionBuffer && + previousCol >= 0 && + col < mappedColumns.length - freezeRightColumns + ) { isEdge = true; bounds = getBoundsForItem(canvas, previousCol, row); assert(bounds !== undefined); @@ -723,6 +741,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, fillHandle, selection, totalHeaderHeight, + freezeColumns, + freezeRightColumns, ] ); @@ -1737,7 +1757,14 @@ const DataGrid: React.ForwardRefRenderFunction = (p, const accessibilityTree = useDebouncedMemo( () => { if (width < 50 || experimental?.disableAccessibilityTree === true) return null; - let effectiveCols = getEffectiveColumns(mappedColumns, cellXOffset, width, dragAndDropState, translateX); + let effectiveCols = getEffectiveColumns( + mappedColumns, + cellXOffset, + width, + freezeColumns, + dragAndDropState, + translateX + ); const colOffset = firstColAccessible ? 0 : -1; if (!firstColAccessible && effectiveCols[0]?.sourceIndex === 0) { effectiveCols = effectiveCols.slice(1); @@ -1871,33 +1898,74 @@ const DataGrid: React.ForwardRefRenderFunction = (p, onKeyDown, getBoundsForItem, onCellFocused, + freezeColumns, ], 200 ); - const opacityX = - freezeColumns === 0 || !fixedShadowX ? 0 : cellXOffset > freezeColumns ? 1 : clamp(-translateX / 100, 0, 1); + const opacityXLeft = + freezeLeftColumns === 0 || !fixedShadowX + ? 0 + : cellXOffset > freezeLeftColumns + ? 1 + : clamp(-translateX / 100, 0, 1); + + let translateXRight = 0; + + if (eventTargetRef?.current) { + translateXRight = eventTargetRef?.current?.scrollLeft + width - eventTargetRef?.current?.scrollWidth; + } + + const opacityXRight = + freezeRightColumns === 0 || !fixedShadowX + ? 0 + : cellXOffset + + getEffectiveColumns( + mappedColumns, + cellXOffset, + width, + freezeColumns, + dragAndDropState, + translateX + ).filter(column => !column.sticky).length < + columns.length - freezeRightColumns + ? 1 + : clamp(-translateXRight / 100, 0, 1); const absoluteOffsetY = -cellYOffset * 32 + translateY; const opacityY = !fixedShadowY ? 0 : clamp(-absoluteOffsetY / 100, 0, 1); const stickyShadow = React.useMemo(() => { - if (!opacityX && !opacityY) { + if (!opacityXLeft && !opacityY && !opacityXRight) { return null; } - const styleX: React.CSSProperties = { + const transition = "opacity 0.2s"; + + const styleXLeft: React.CSSProperties = { position: "absolute", top: 0, - left: stickyX, - width: width - stickyX, + left: stickyX[0], + width: width - stickyX[0], height: height, - opacity: opacityX, + opacity: opacityXLeft, pointerEvents: "none", - transition: !smoothScrollX ? "opacity 0.2s" : undefined, + transition: !smoothScrollX ? transition : undefined, boxShadow: "inset 13px 0 10px -13px rgba(0, 0, 0, 0.2)", }; + const styleXRight: React.CSSProperties = { + position: "absolute", + top: 0, + right: stickyX[1], + width: width - stickyX[1], + height: height, + opacity: opacityXRight, + pointerEvents: "none", + transition: !smoothScrollX ? transition : undefined, + boxShadow: "inset -13px 0 10px -13px rgba(0, 0, 0, 0.2)", + }; + const styleY: React.CSSProperties = { position: "absolute", top: totalHeaderHeight, @@ -1906,17 +1974,28 @@ const DataGrid: React.ForwardRefRenderFunction = (p, height: height, opacity: opacityY, pointerEvents: "none", - transition: !smoothScrollY ? "opacity 0.2s" : undefined, + transition: !smoothScrollY ? transition : undefined, boxShadow: "inset 0 13px 10px -13px rgba(0, 0, 0, 0.2)", }; return ( <> - {opacityX > 0 &&
} + {opacityXLeft > 0 &&
} + {opacityXRight > 0 &&
} {opacityY > 0 &&
} ); - }, [opacityX, opacityY, stickyX, width, smoothScrollX, totalHeaderHeight, height, smoothScrollY]); + }, [ + opacityXLeft, + opacityY, + stickyX, + width, + smoothScrollX, + totalHeaderHeight, + height, + smoothScrollY, + opacityXRight, + ]); const overlayStyle = React.useMemo( () => ({ diff --git a/packages/core/src/internal/data-grid/image-window-loader-interface.ts b/packages/core/src/internal/data-grid/image-window-loader-interface.ts index e32ef93d1..7cadb0fca 100644 --- a/packages/core/src/internal/data-grid/image-window-loader-interface.ts +++ b/packages/core/src/internal/data-grid/image-window-loader-interface.ts @@ -3,7 +3,12 @@ import type { Rectangle } from "./data-grid-types.js"; /** @category Types */ export interface ImageWindowLoader { - setWindow(newWindow: Rectangle, freezeCols: number, freezeRows: number[]): void; + setWindow( + newWindow: Rectangle, + freezeCols: number | readonly [left: number, right: number], + freezeRows: number[], + columnsLength: number + ): void; loadOrGetImage(url: string, col: number, row: number): HTMLImageElement | ImageBitmap | undefined; setCallback(imageLoaded: (locations: CellSet) => void): void; } diff --git a/packages/core/src/internal/data-grid/render/data-grid-lib.ts b/packages/core/src/internal/data-grid/render/data-grid-lib.ts index 96eb1d90f..95497f955 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-lib.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-lib.ts @@ -8,7 +8,7 @@ import { type Rectangle, type BaseGridCell, } from "../data-grid-types.js"; -import { direction } from "../../../common/utils.js"; +import { direction, normalizeFreezeColumns } from "../../../common/utils.js"; import React from "react"; import type { BaseDrawArgs, PrepResult } from "../../../cells/cell-types.js"; import { split as splitText, clearCache } from "canvas-hypertxt"; @@ -17,12 +17,18 @@ import type { FullyDefined } from "../../../common/support.js"; export interface MappedGridColumn extends FullyDefined { sourceIndex: number; sticky: boolean; + stickyPosition: "left" | "right" | undefined; } export function useMappedColumns( columns: readonly InnerGridColumn[], - freezeColumns: number + freezeColumns: number | readonly [left: number, right: number] ): readonly MappedGridColumn[] { + // Extract freeze column counts from the union type parameter. freezeColumnsLeft and freezeColumnsRight + // determine which columns should remain sticky at the left and right sides respectively during horizontal scrolling. + const freezeColumnsLeft = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeColumnsRight = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + return React.useMemo( () => columns.map( @@ -35,7 +41,9 @@ export function useMappedColumns( menuIcon: c.menuIcon, overlayIcon: c.overlayIcon, sourceIndex: i, - sticky: i < freezeColumns, + sticky: i < freezeColumnsLeft || i >= columns.length - freezeColumnsRight, + stickyPosition: + i < freezeColumnsLeft ? "left" : i >= columns.length - freezeColumnsRight ? "right" : undefined, indicatorIcon: c.indicatorIcon, style: c.style, themeOverride: c.themeOverride, @@ -50,7 +58,7 @@ export function useMappedColumns( headerRowMarkerDisabled: c.headerRowMarkerDisabled, }) ), - [columns, freezeColumns] + [columns, freezeColumnsLeft, freezeColumnsRight] ); } @@ -174,16 +182,25 @@ export function getStickyWidth( src: number; dest: number; } -): number { - let result = 0; +): [left: number, right: number] { + let lWidth = 0; + let rWidth = 0; const remapped = remapForDnDState(columns, dndState); for (let i = 0; i < remapped.length; i++) { const c = remapped[i]; - if (c.sticky) result += c.width; - else break; + if (c.sticky) { + if (c.stickyPosition === "left") lWidth += c.width; + } else break; } - return result; + for (let i = remapped.length - 1; i >= 0; i--) { + const c = remapped[i]; + if (c.sticky) { + if (c.stickyPosition === "right") rWidth += c.width; + } else break; + } + + return [lWidth, rWidth]; } export function getFreezeTrailingHeight( @@ -206,6 +223,7 @@ export function getEffectiveColumns( columns: readonly MappedGridColumn[], cellXOffset: number, width: number, + freezeColumns: number | readonly [left: number, right: number], dndState?: { src: number; dest: number; @@ -214,19 +232,31 @@ export function getEffectiveColumns( ): readonly MappedGridColumn[] { const mappedCols = remapForDnDState(columns, dndState); + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); + const sticky: MappedGridColumn[] = []; - for (const c of mappedCols) { - if (c.sticky) { - sticky.push(c); - } else { - break; - } + for (let i = 0; i < freezeLeftColumns; i++) { + sticky.push(mappedCols[i]); } + if (sticky.length > 0) { for (const c of sticky) { width -= c.width; } } + + const stickyRight: MappedGridColumn[] = []; + + for (let i = mappedCols.length - freezeRightColumns; i < mappedCols.length; i++) { + stickyRight.push(mappedCols[i]); + } + + if (stickyRight.length > 0) { + for (const c of stickyRight) { + width -= c.width; + } + } + let endIndex = cellXOffset; let curX = tx ?? 0; @@ -242,22 +272,40 @@ export function getEffectiveColumns( } } + sticky.push(...stickyRight); + return sticky; } export function getColumnIndexForX( targetX: number, effectiveColumns: readonly MappedGridColumn[], + freezeColumns: number | readonly [left: number, right: number], + width: number, translateX?: number ): number { + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + + let y = width; + for (let fc = 0; fc < freezeRightColumns; fc++) { + const colIdx = effectiveColumns.length - 1 - fc; + const col = effectiveColumns[colIdx]; + y -= col.width; + if (targetX >= y) { + return col.sourceIndex; + } + } + let x = 0; - for (const c of effectiveColumns) { + for (let i = 0; i < effectiveColumns.length - freezeRightColumns; i++) { + const c = effectiveColumns[i]; const cx = c.sticky ? x : x + (translateX ?? 0); if (targetX <= cx + c.width) { return c.sourceIndex; } x += c.width; } + return -1; } @@ -768,7 +816,7 @@ export function computeBounds( translateX: number, translateY: number, rows: number, - freezeColumns: number, + freezeColumns: number | readonly [left: number, right: number], freezeTrailingRows: number, mappedColumns: readonly MappedGridColumn[], rowHeight: number | ((index: number) => number) @@ -780,23 +828,31 @@ export function computeBounds( height: 0, }; + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); + const column = mappedColumns[col]; + if (col >= mappedColumns.length || row >= rows || row < -2 || col < 0) { return result; } const headerHeight = totalHeaderHeight - groupHeaderHeight; - if (col >= freezeColumns) { + if (col >= freezeLeftColumns && col < mappedColumns.length - freezeRightColumns) { const dir = cellXOffset > col ? -1 : 1; - const freezeWidth = getStickyWidth(mappedColumns); - result.x += freezeWidth + translateX; + const [freezeLeftWidth] = getStickyWidth(mappedColumns); + result.x += freezeLeftWidth + translateX; for (let i = cellXOffset; i !== col; i += dir) { result.x += mappedColumns[dir === 1 ? i : i - 1].width * dir; } - } else { + } else if (column.stickyPosition === "left") { for (let i = 0; i < col; i++) { result.x += mappedColumns[i].width; } + } else if (column.stickyPosition === "right") { + result.x = width; + for (let i = col; i < mappedColumns.length; i++) { + result.x -= mappedColumns[i].width; + } } result.width = mappedColumns[col].width + 1; @@ -832,8 +888,8 @@ export function computeBounds( end++; } if (!sticky) { - const freezeWidth = getStickyWidth(mappedColumns); - const clip = result.x - freezeWidth; + const [freezeLeftWidth] = getStickyWidth(mappedColumns); + const clip = result.x - freezeLeftWidth; if (clip < 0) { result.x -= clip; result.width += clip; diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.blit.ts b/packages/core/src/internal/data-grid/render/data-grid-render.blit.ts index 4f5600837..dbf777cfd 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.blit.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.blit.ts @@ -70,7 +70,7 @@ export function blitLastFrame( } deltaX += translateX - last.translateX; - const stickyWidth = getStickyWidth(effectiveCols); + const [stickyLeftWidth, stickyRightWidth] = getStickyWidth(effectiveCols); if (deltaX !== 0 && deltaY !== 0) { return { @@ -81,7 +81,7 @@ export function blitLastFrame( const freezeTrailingRowsHeight = freezeTrailingRows > 0 ? getFreezeTrailingHeight(rows, freezeTrailingRows, getRowHeight) : 0; - const blitWidth = width - stickyWidth - Math.abs(deltaX); + const blitWidth = width - stickyLeftWidth - Math.abs(deltaX) - stickyRightWidth; const blitHeight = height - totalHeaderHeight - freezeTrailingRowsHeight - Math.abs(deltaY) - 1; if (blitWidth > 150 && blitHeight > 150) { @@ -128,28 +128,28 @@ export function blitLastFrame( // blit X if (deltaX > 0) { // pixels moving right - args.sx = stickyWidth * dpr; + args.sx = stickyLeftWidth * dpr; args.sw = blitWidth * dpr; - args.dx = (deltaX + stickyWidth) * dpr; + args.dx = (deltaX + stickyLeftWidth) * dpr; args.dw = blitWidth * dpr; drawRegions.push({ - x: stickyWidth - 1, + x: stickyLeftWidth - 1, y: 0, width: deltaX + 2, // extra width to account for first col not drawing a left side border height: height, }); } else if (deltaX < 0) { // pixels moving left - args.sx = (stickyWidth - deltaX) * dpr; + args.sx = (stickyLeftWidth - deltaX) * dpr; args.sw = blitWidth * dpr; - args.dx = stickyWidth * dpr; + args.dx = stickyLeftWidth * dpr; args.dw = blitWidth * dpr; drawRegions.push({ - x: width + deltaX, + x: width + deltaX - stickyRightWidth, y: 0, - width: -deltaX, + width: -deltaX + stickyRightWidth, height: height, }); } @@ -157,17 +157,28 @@ export function blitLastFrame( ctx.setTransform(1, 0, 0, 1, 0, 0); if (doubleBuffer) { if ( - stickyWidth > 0 && + stickyLeftWidth > 0 && deltaX !== 0 && deltaY === 0 && (targetScroll === undefined || blitSourceScroll?.[1] !== false) ) { // When double buffering the freeze columns can be offset by a couple pixels vertically between the two // buffers. We don't want to redraw them so we need to make sure to copy them between the buffers. - const w = stickyWidth * dpr; + const w = stickyLeftWidth * dpr; const h = height * dpr; ctx.drawImage(blitSource, 0, 0, w, h, 0, 0, w, h); } + if ( + stickyRightWidth > 0 && + deltaX !== 0 && + deltaY === 0 && + (targetScroll === undefined || blitSourceScroll?.[1] !== false) + ) { + const x = (width - stickyRightWidth) * dpr; + const w = stickyRightWidth * dpr; + const h = height * dpr; + ctx.drawImage(blitSource, x, 0, w, h, x, 0, w, h); + } if ( freezeTrailingRowsHeight > 0 && deltaX === 0 && @@ -200,7 +211,8 @@ export function blitResizedCol( height: number, totalHeaderHeight: number, effectiveCols: readonly MappedGridColumn[], - resizedIndex: number + resizedIndex: number, + freezeTrailingColumns: number ) { const drawRegions: Rectangle[] = []; @@ -215,18 +227,27 @@ export function blitResizedCol( return drawRegions; } - walkColumns(effectiveCols, cellYOffset, translateX, translateY, totalHeaderHeight, (c, drawX, _drawY, clipX) => { - if (c.sourceIndex === resizedIndex) { - const x = Math.max(drawX, clipX) + 1; - drawRegions.push({ - x, - y: 0, - width: width - x, - height, - }); - return true; + walkColumns( + effectiveCols, + width, + cellYOffset, + translateX, + translateY, + totalHeaderHeight, + freezeTrailingColumns, + (c, drawX, _drawY, clipX) => { + if (c.sourceIndex === resizedIndex) { + const x = Math.max(drawX, clipX) + 1; + drawRegions.push({ + x, + y: 0, + width: width - x, + height, + }); + return true; + } } - }); + ); return drawRegions; } diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts b/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts index dc84326c2..48d088b2e 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts @@ -77,6 +77,7 @@ export function drawCells( effectiveColumns: readonly MappedGridColumn[], allColumns: readonly MappedGridColumn[], height: number, + width: number, totalHeaderHeight: number, translateX: number, translateY: number, @@ -90,6 +91,7 @@ export function drawCells( isFocused: boolean, drawFocus: boolean, freezeTrailingRows: number, + freezeTrailingColumns: number, hasAppendRow: boolean, drawRegions: readonly Rectangle[], damage: CellSet | undefined, @@ -124,16 +126,18 @@ export function drawCells( walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, - (c, drawX, colDrawStartY, clipX, startRow) => { + freezeTrailingColumns, + (c, drawX, colDrawStartY, clipX, clipXRight, startRow) => { const diff = Math.max(0, clipX - drawX); const colDrawX = drawX + diff; const colDrawY = totalHeaderHeight + 1; - const colWidth = c.width - diff; + const colWidth = c.stickyPosition === "right" ? c.width - diff : Math.min(c.width - diff, width - drawX - clipXRight); const colHeight = height - totalHeaderHeight - 1; if (drawRegions.length > 0) { let found = false; @@ -295,7 +299,7 @@ export function drawCells( const bgCell = cell.kind === GridCellKind.Protected ? theme.bgCellMedium : theme.bgCell; let fill: string | undefined; - if (isSticky || bgCell !== outerTheme.bgCell) { + if (isSticky || bgCell !== outerTheme.bgCell || c.sticky) { fill = blend(bgCell, fill); } @@ -357,6 +361,35 @@ export function drawCells( fill = fill === undefined ? theme.bgCell : blend(fill, theme.bgCell); } + // Full-redraw counterpart to the damage-path clamp above. Columns draw left-to-right and + // each paints its scrolling rows before the next column runs, so a scrolling row whose + // bottom crosses into the frozen band would overwrite a spanning frozen row that an + // earlier column already drew. Clip such straddling rows to the freeze boundary so the + // frozen band stays reserved for frozen rows only. + // + // Exception: a span drawn by a sticky (frozen) column — e.g. a group-header row whose + // label lives in the frozen columns. Sticky columns are re-composited over the blitted + // frame every draw, and clipping that composite to the freeze boundary leaves the group + // row's background a shade darker than its scrollable half (a visible two-tone seam that + // accumulates while scrolling). Such a straddling group span doesn't need the clip anyway: + // the frozen trailing rows are painted afterwards in each column's sticky pass and cover + // any bleed. So skip the clip for sticky spans; keep it for sticky non-span cells (which + // still must not bleed into the band) and for all scrollable cells. + let didFreezeClip = false; + if ( + damage === undefined && + !isSticky && + !(c.sticky && drawingSpan) && + freezeTrailingRowsHeight > 0 && + drawY + rh > height - freezeTrailingRowsHeight + ) { + didFreezeClip = true; + ctx.save(); + ctx.beginPath(); + ctx.rect(cellX, drawY, cellWidth, height - freezeTrailingRowsHeight - drawY); + ctx.clip(); + } + const isLastColumn = c.sourceIndex === allColumns.length - 1; const isLastRow = row === rows - 1; if (fill !== undefined) { @@ -430,6 +463,10 @@ export function drawCells( ctx.restore(); } + if (didFreezeClip) { + ctx.restore(); + } + if (cell.style === "faded") { ctx.globalAlpha = 1; } @@ -551,11 +588,11 @@ export function drawCell( partialPrepResult === undefined ? undefined : { - deprep: partialPrepResult?.deprep, - fillStyle: partialPrepResult?.fillStyle, - font: partialPrepResult?.font, - renderer: r, - }; + deprep: partialPrepResult?.deprep, + fillStyle: partialPrepResult?.fillStyle, + font: partialPrepResult?.font, + renderer: r, + }; } if (needsAnim || animationFrameRequested) enqueue?.(allocatedItem); diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.header.ts b/packages/core/src/internal/data-grid/render/data-grid-render.header.ts index 47117f530..9e053452d 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.header.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.header.ts @@ -38,7 +38,8 @@ export function drawGridHeaders( getGroupDetails: GroupDetailsCallback, damage: CellSet | undefined, drawHeaderCallback: DrawHeaderCallback | undefined, - touchMode: boolean + touchMode: boolean, + freezeTrailingColumns: number ) { const totalHeaderHeight = headerHeight + groupHeaderHeight; if (totalHeaderHeight <= 0) return; @@ -54,80 +55,107 @@ export function drawGridHeaders( const font = outerTheme.headerFontFull; // Assinging the context font too much can be expensive, it can be worth it to minimze this ctx.font = font; - walkColumns(effectiveCols, 0, translateX, 0, totalHeaderHeight, (c, x, _y, clipX) => { - if (damage !== undefined && !damage.has([c.sourceIndex, -1])) return; - const diff = Math.max(0, clipX - x); - ctx.save(); - ctx.beginPath(); - ctx.rect(x + diff, groupHeaderHeight, c.width - diff, headerHeight); - ctx.clip(); - - const groupTheme = getGroupDetails(c.group ?? "").overrideTheme; - const theme = - c.themeOverride === undefined && groupTheme === undefined - ? outerTheme - : mergeAndRealizeTheme(outerTheme, groupTheme, c.themeOverride); - - if (theme.bgHeader !== outerTheme.bgHeader) { - ctx.fillStyle = theme.bgHeader; - ctx.fill(); - } - - if (theme !== outerTheme) { - ctx.font = theme.headerFontFull; - } - const selected = selection.columns.hasIndex(c.sourceIndex); - const noHover = dragAndDropState !== undefined || isResizing || c.headerRowMarkerDisabled === true; - const hoveredBoolean = !noHover && hRow === -1 && hCol === c.sourceIndex; - const hover = noHover - ? 0 - : (hoverValues.find(s => s.item[0] === c.sourceIndex && s.item[1] === -1)?.hoverAmount ?? 0); - - const hasSelectedCell = selection?.current !== undefined && selection.current.cell[0] === c.sourceIndex; - - const bgFillStyle = selected ? theme.accentColor : hasSelectedCell ? theme.bgHeaderHasFocus : theme.bgHeader; + walkColumns( + effectiveCols, + width, + 0, + translateX, + 0, + totalHeaderHeight, + freezeTrailingColumns, + (c, x, _y, clipX, clipXRight) => { + if (damage !== undefined && !damage.has([c.sourceIndex, -1])) return; + const diff = Math.max(0, clipX - x); + + let rectWidth = + c.stickyPosition === "right" ? c.width - diff : Math.min(c.width - diff, width - x - clipXRight); + + ctx.save(); + ctx.beginPath(); + ctx.rect(x + diff, groupHeaderHeight, rectWidth, headerHeight); + ctx.clip(); - const y = enableGroups ? groupHeaderHeight : 0; - const xOffset = c.sourceIndex === 0 ? 0 : 1; + const groupTheme = getGroupDetails(c.group ?? "").overrideTheme; + const theme = + c.themeOverride === undefined && groupTheme === undefined + ? outerTheme + : mergeAndRealizeTheme(outerTheme, groupTheme, c.themeOverride); - if (selected) { - ctx.fillStyle = bgFillStyle; - ctx.fillRect(x + xOffset, y, c.width - xOffset, headerHeight); - } else if (hasSelectedCell || hover > 0) { - ctx.beginPath(); - ctx.rect(x + xOffset, y, c.width - xOffset, headerHeight); - if (hasSelectedCell) { - ctx.fillStyle = theme.bgHeaderHasFocus; + if (c.sticky === true) { + ctx.fillStyle = theme.bgHeader; ctx.fill(); } - if (hover > 0) { - ctx.globalAlpha = hover; - ctx.fillStyle = theme.bgHeaderHovered; + + if (theme.bgHeader !== outerTheme.bgHeader) { + ctx.fillStyle = theme.bgHeader; ctx.fill(); - ctx.globalAlpha = 1; } - } - drawHeader( - ctx, - x, - y, - c.width, - headerHeight, - c, - selected, - theme, - hoveredBoolean, - hoveredBoolean ? hPosX : undefined, - hoveredBoolean ? hPosY : undefined, - hasSelectedCell, - hover, - spriteManager, - drawHeaderCallback, - touchMode - ); - ctx.restore(); - }); + if (theme !== outerTheme) { + ctx.font = theme.headerFontFull; + } + const selected = selection.columns.hasIndex(c.sourceIndex); + const noHover = dragAndDropState !== undefined || isResizing || c.headerRowMarkerDisabled === true; + const hoveredBoolean = !noHover && hRow === -1 && hCol === c.sourceIndex; + const hover = noHover + ? 0 + : (hoverValues.find(s => s.item[0] === c.sourceIndex && s.item[1] === -1)?.hoverAmount ?? 0); + + const hasSelectedCell = selection?.current !== undefined && selection.current.cell[0] === c.sourceIndex; + + const bgFillStyle = selected + ? theme.accentColor + : hasSelectedCell + ? theme.bgHeaderHasFocus + : theme.bgHeader; + + const y = enableGroups ? groupHeaderHeight : 0; + const xOffset = c.sourceIndex === 0 ? 0 : 1; + + if (selected) { + ctx.fillStyle = bgFillStyle; + ctx.fillRect(x + xOffset, y, c.width - xOffset, headerHeight); + } else if (hasSelectedCell || hover > 0) { + rectWidth = + c.stickyPosition === "right" + ? c.width - xOffset + : Math.min(c.width - xOffset, width - x - clipXRight); + + ctx.beginPath(); + ctx.rect(x + xOffset, y, rectWidth, headerHeight); + if (hasSelectedCell) { + ctx.fillStyle = theme.bgHeaderHasFocus; + ctx.fill(); + } + if (hover > 0) { + ctx.globalAlpha = hover; + ctx.fillStyle = theme.bgHeaderHovered; + ctx.fill(); + ctx.globalAlpha = 1; + } + } + + drawHeader( + ctx, + x, + y, + c.width, + headerHeight, + c, + selected, + theme, + hoveredBoolean, + hoveredBoolean ? hPosX : undefined, + hoveredBoolean ? hPosY : undefined, + hasSelectedCell, + hover, + spriteManager, + drawHeaderCallback, + touchMode + ); + ctx.restore(); + } + ); if (enableGroups) { drawGroups( @@ -142,7 +170,8 @@ export function drawGridHeaders( hoverValues, verticalBorder, getGroupDetails, - damage + damage, + freezeTrailingColumns ); } } @@ -159,120 +188,128 @@ export function drawGroups( _hoverValues: HoverValues, verticalBorder: (col: number) => boolean, getGroupDetails: GroupDetailsCallback, - damage: CellSet | undefined + damage: CellSet | undefined, + freezeTrailingColumns: number ) { const xPad = 8; const [hCol, hRow] = hovered?.[0] ?? []; let finalX = 0; - walkGroups(effectiveCols, width, translateX, groupHeaderHeight, (span, groupName, x, y, w, h) => { - if ( - damage !== undefined && - !damage.hasItemInRectangle({ - x: span[0], - y: -2, - width: span[1] - span[0] + 1, - height: 1, - }) - ) - return; - ctx.save(); - ctx.beginPath(); - ctx.rect(x, y, w, h); - ctx.clip(); - - const group = getGroupDetails(groupName); - const groupTheme = - group?.overrideTheme === undefined ? theme : mergeAndRealizeTheme(theme, group.overrideTheme); - const isHovered = hRow === -2 && hCol !== undefined && hCol >= span[0] && hCol <= span[1]; - const fillColor = isHovered - ? (groupTheme.bgGroupHeaderHovered ?? groupTheme.bgHeaderHovered) - : (groupTheme.bgGroupHeader ?? groupTheme.bgHeader); - - if (fillColor !== theme.bgHeader) { - ctx.fillStyle = fillColor; - ctx.fill(); - } - - ctx.fillStyle = groupTheme.textGroupHeader ?? groupTheme.textHeader; - if (group !== undefined) { - let drawX = x; - if (group.icon !== undefined) { - spriteManager.drawSprite( - group.icon, - "normal", - ctx, - drawX + xPad, - (groupHeaderHeight - 20) / 2, - 20, - groupTheme - ); - drawX += 26; - } - ctx.fillText( - group.name, - drawX + xPad, - groupHeaderHeight / 2 + getMiddleCenterBias(ctx, theme.headerFontFull) - ); - - if (group.actions !== undefined && isHovered) { - const actionBoxes = getActionBoundsForGroup({ x, y, width: w, height: h }, group.actions); - - ctx.beginPath(); - const fadeStartX = actionBoxes[0].x - 10; - const fadeWidth = x + w - fadeStartX; - ctx.rect(fadeStartX, 0, fadeWidth, groupHeaderHeight); - const grad = ctx.createLinearGradient(fadeStartX, 0, fadeStartX + fadeWidth, 0); - const trans = withAlpha(fillColor, 0); - grad.addColorStop(0, trans); - grad.addColorStop(10 / fadeWidth, fillColor); - grad.addColorStop(1, fillColor); - ctx.fillStyle = grad; - + walkGroups( + effectiveCols, + width, + translateX, + groupHeaderHeight, + freezeTrailingColumns, + (span, groupName, x, y, w, h) => { + if ( + damage !== undefined && + !damage.hasItemInRectangle({ + x: span[0], + y: -2, + width: span[1] - span[0] + 1, + height: 1, + }) + ) + return; + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + + const group = getGroupDetails(groupName); + const groupTheme = + group?.overrideTheme === undefined ? theme : mergeAndRealizeTheme(theme, group.overrideTheme); + const isHovered = hRow === -2 && hCol !== undefined && hCol >= span[0] && hCol <= span[1]; + const fillColor = isHovered + ? (groupTheme.bgGroupHeaderHovered ?? groupTheme.bgHeaderHovered) + : (groupTheme.bgGroupHeader ?? groupTheme.bgHeader); + + if (fillColor !== theme.bgHeader) { + ctx.fillStyle = fillColor; ctx.fill(); + } - ctx.globalAlpha = 0.6; - - // eslint-disable-next-line prefer-const - const [mouseX, mouseY] = hovered?.[1] ?? [-1, -1]; - for (let i = 0; i < group.actions.length; i++) { - const action = group.actions[i]; - const box = actionBoxes[i]; - const actionHovered = pointInRect(box, mouseX + x, mouseY); - if (actionHovered) { - ctx.globalAlpha = 1; - } + ctx.fillStyle = groupTheme.textGroupHeader ?? groupTheme.textHeader; + if (group !== undefined) { + let drawX = x; + if (group.icon !== undefined) { spriteManager.drawSprite( - action.icon, + group.icon, "normal", ctx, - box.x + box.width / 2 - 10, - box.y + box.height / 2 - 10, + drawX + xPad, + (groupHeaderHeight - 20) / 2, 20, groupTheme ); - if (actionHovered) { - ctx.globalAlpha = 0.6; - } + drawX += 26; } + ctx.fillText( + group.name, + drawX + xPad, + groupHeaderHeight / 2 + getMiddleCenterBias(ctx, theme.headerFontFull) + ); + + if (group.actions !== undefined && isHovered) { + const actionBoxes = getActionBoundsForGroup({ x, y, width: w, height: h }, group.actions); + + ctx.beginPath(); + const fadeStartX = actionBoxes[0].x - 10; + const fadeWidth = x + w - fadeStartX; + ctx.rect(fadeStartX, 0, fadeWidth, groupHeaderHeight); + const grad = ctx.createLinearGradient(fadeStartX, 0, fadeStartX + fadeWidth, 0); + const trans = withAlpha(fillColor, 0); + grad.addColorStop(0, trans); + grad.addColorStop(10 / fadeWidth, fillColor); + grad.addColorStop(1, fillColor); + ctx.fillStyle = grad; + + ctx.fill(); + + ctx.globalAlpha = 0.6; + + // eslint-disable-next-line prefer-const + const [mouseX, mouseY] = hovered?.[1] ?? [-1, -1]; + for (let i = 0; i < group.actions.length; i++) { + const action = group.actions[i]; + const box = actionBoxes[i]; + const actionHovered = pointInRect(box, mouseX + x, mouseY); + if (actionHovered) { + ctx.globalAlpha = 1; + } + spriteManager.drawSprite( + action.icon, + "normal", + ctx, + box.x + box.width / 2 - 10, + box.y + box.height / 2 - 10, + 20, + groupTheme + ); + if (actionHovered) { + ctx.globalAlpha = 0.6; + } + } - ctx.globalAlpha = 1; + ctx.globalAlpha = 1; + } } - } - if (x !== 0 && verticalBorder(span[0])) { - ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, groupHeaderHeight); - ctx.strokeStyle = theme.borderColor; - ctx.lineWidth = 1; - ctx.stroke(); - } + if (x !== 0 && verticalBorder(span[0])) { + ctx.beginPath(); + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, groupHeaderHeight); + ctx.strokeStyle = theme.borderColor; + ctx.lineWidth = 1; + ctx.stroke(); + } - ctx.restore(); + ctx.restore(); - finalX = x + w; - }); + finalX = x + w; + } + ); ctx.beginPath(); ctx.moveTo(finalX + 0.5, 0); diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.lines.ts b/packages/core/src/internal/data-grid/render/data-grid-render.lines.ts index cd4b4cf8d..b1ebf4baf 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.lines.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.lines.ts @@ -26,6 +26,7 @@ export function drawBlanks( selectedRows: CompactSelection, disabledRows: CompactSelection, freezeTrailingRows: number, + freezeTrailingColumns: number, hasAppendRow: boolean, drawRegions: readonly Rectangle[], damage: CellSet | undefined, @@ -41,11 +42,13 @@ export function drawBlanks( walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, - (c, drawX, colDrawY, clipX, startRow) => { + freezeTrailingColumns, + (c, drawX, colDrawY, clipX, _clipXRight, startRow) => { if (c !== effectiveColumns[effectiveColumns.length - 1]) return; drawX += c.width; const x = Math.max(drawX, clipX); @@ -123,18 +126,27 @@ export function overdrawStickyBoundaries( } const hColor = theme.horizontalBorderColor ?? theme.borderColor; const vColor = theme.borderColor; - const drawX = drawFreezeBorder ? getStickyWidth(effectiveCols) : 0; + const [drawXLeft, drawXRight] = drawFreezeBorder ? getStickyWidth(effectiveCols) : [0, 0]; let vStroke: string | undefined; - if (drawX !== 0) { + if (drawXLeft !== 0) { vStroke = blendCache(vColor, theme.bgCell); ctx.beginPath(); - ctx.moveTo(drawX + 0.5, 0); - ctx.lineTo(drawX + 0.5, height); + ctx.moveTo(drawXLeft + 0.5, 0); + ctx.lineTo(drawXLeft + 0.5, height); ctx.strokeStyle = vStroke; ctx.stroke(); } + if (drawXRight !== 0) { + const hStroke = vColor === hColor && vStroke !== undefined ? vStroke : blendCache(hColor, theme.bgCell); + ctx.beginPath(); + ctx.moveTo(width - drawXRight + 0.5, 0); + ctx.lineTo(width - drawXRight + 0.5, height); + ctx.strokeStyle = hStroke; + ctx.stroke(); + } + if (freezeTrailingRows > 0) { const hStroke = vColor === hColor && vStroke !== undefined ? vStroke : blendCache(hColor, theme.bgCell); const h = getFreezeTrailingHeight(rows, freezeTrailingRows, getRowHeight); @@ -305,6 +317,8 @@ export function drawGridLines( } ctx.clip("evenodd"); } + + const effectiveWidth = effectiveCols.reduce((acc, col) => acc + col.width, 0); const hColor = theme.horizontalBorderColor ?? theme.borderColor; const vColor = theme.borderColor; @@ -319,6 +333,7 @@ export function drawGridLines( for (let index = 0; index < effectiveCols.length; index++) { const c = effectiveCols[index]; if (c.width === 0) continue; + if (effectiveCols[index + 1]?.sticky && effectiveCols[index + 1].stickyPosition !== "left") break; x += c.width; const tx = c.sticky ? x : x + translateX; if (tx >= minX && tx <= maxX && verticalBorder(index + 1)) { @@ -332,6 +347,25 @@ export function drawGridLines( } } + const clippedWidth = Math.min(width, effectiveWidth); + let rightX = clippedWidth + 0.5; + for (let index = effectiveCols.length - 1; index >= 0; index--) { + const c = effectiveCols[index]; + if (c.width === 0) continue; + if (!c.sticky) break; + rightX -= c.width; + const tx = rightX; + if (tx >= minX && tx <= maxX && verticalBorder(index + 1)) { + toDraw.push({ + x1: tx, + y1: Math.max(groupHeaderHeight, minY), + x2: tx, + y2: Math.min(height, maxY), + color: vColor, + }); + } + } + let freezeY = height + 0.5; for (let i = rows - freezeTrailingRows; i < rows; i++) { const rh = getRowHeight(i); diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.ts b/packages/core/src/internal/data-grid/render/data-grid-render.ts index 310c3511d..05d3ed284 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.ts @@ -12,6 +12,7 @@ import { drawGridHeaders } from "./data-grid-render.header.js"; import { drawGridLines, overdrawStickyBoundaries, drawBlanks, drawExtraRowThemes } from "./data-grid-render.lines.js"; import { blitLastFrame, blitResizedCol, computeCanBlit } from "./data-grid-render.blit.js"; import { drawHighlightRings, drawFillHandle, drawColumnResizeOutline } from "./data-grid.render.rings.js"; +import { normalizeFreezeColumns } from "../../../common/utils.js"; // Future optimization opportunities // - Create a cache of a buffer used to render the full view of a partially displayed column so that when @@ -33,35 +34,48 @@ function clipHeaderDamage( translateX: number, translateY: number, cellYOffset: number, + freezeTrailingColumns: number, damage: CellSet | undefined ): void { if (damage === undefined || damage.size === 0) return; ctx.beginPath(); - walkGroups(effectiveColumns, width, translateX, groupHeaderHeight, (span, _group, x, y, w, h) => { - const hasItemInSpan = damage.hasItemInRectangle({ - x: span[0], - y: -2, - width: span[1] - span[0] + 1, - height: 1, - }); - if (hasItemInSpan) { - ctx.rect(x, y, w, h); + walkGroups( + effectiveColumns, + width, + translateX, + groupHeaderHeight, + freezeTrailingColumns, + (span, _group, x, y, w, h) => { + const hasItemInSpan = damage.hasItemInRectangle({ + x: span[0], + y: -2, + width: span[1] - span[0] + 1, + height: 1, + }); + if (hasItemInSpan) { + ctx.rect(x, y, w, h); + } } - }); + ); walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, - (c, drawX, _colDrawY, clipX) => { + freezeTrailingColumns, + (c, drawX, _colDrawY, clipX, clipXRight) => { const diff = Math.max(0, clipX - drawX); const finalX = drawX + diff + 1; - const finalWidth = c.width - diff - 1; + const finalWidth = + c.stickyPosition === "right" + ? c.width - diff + : Math.min(c.width - diff - 1, width - drawX - clipXRight); if (damage.has([c.sourceIndex, -1])) { ctx.rect(finalX, groupHeaderHeight, finalWidth, totalHeaderHeight - groupHeaderHeight); } @@ -73,6 +87,7 @@ function clipHeaderDamage( function getLastRow( effectiveColumns: readonly MappedGridColumn[], height: number, + width: number, totalHeaderHeight: number, translateX: number, translateY: number, @@ -80,16 +95,19 @@ function getLastRow( rows: number, getRowHeight: (row: number) => number, freezeTrailingRows: number, - hasAppendRow: boolean + hasAppendRow: boolean, + freezeTrailingColumns: number ): number { let result = 0; walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, - (_c, __drawX, colDrawY, _clipX, startRow) => { + freezeTrailingColumns, + (_c, __drawX, colDrawY, _clipX, _clipXRight, startRow) => { walkRowsInCol( startRow, colDrawY, @@ -171,6 +189,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { const doubleBuffer = renderStrategy === "double-buffer"; const dpr = Math.min(maxScaleFactor, Math.ceil(window.devicePixelRatio ?? 1)); + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); + // if we are double buffering we need to make sure we can blit. If we can't we need to redraw the whole thing const canBlit = renderStrategy !== "direct" && computeCanBlit(arg, lastArg); @@ -253,7 +273,14 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { targetCtx.scale(dpr, dpr); } - const effectiveCols = getEffectiveColumns(mappedColumns, cellXOffset, width, dragAndDropState, translateX); + const effectiveCols = getEffectiveColumns( + mappedColumns, + cellXOffset, + width, + freezeColumns, + dragAndDropState, + translateX + ); let drawRegions: Rectangle[] = []; @@ -287,7 +314,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getGroupDetails, damage, drawHeaderCallback, - touchMode + touchMode, + freezeRightColumns ); drawGridLines( @@ -357,6 +385,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -383,13 +412,13 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { { x: 0, y: cellYOffset, - width: freezeColumns, + width: freezeLeftColumns, height: 300, }, { x: 0, y: -2, - width: freezeColumns, + width: freezeLeftColumns, height: 2, }, { @@ -399,6 +428,18 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { height: freezeTrailingRows, when: freezeTrailingRows > 0, }, + { + x: viewRegionWidth - freezeRightColumns, + y: cellYOffset, + width: freezeRightColumns, + height: 300, + }, + { + x: viewRegionWidth - freezeRightColumns, + y: -2, + width: freezeRightColumns, + height: 2, + }, ]); const doDamage = (ctx: CanvasRenderingContext2D) => { @@ -407,6 +448,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { effectiveCols, mappedColumns, height, + width, totalHeaderHeight, translateX, translateY, @@ -420,6 +462,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { isFocused, drawFocus, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -463,6 +506,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -491,6 +535,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { translateX, translateY, cellYOffset, + freezeRightColumns, damage ); drawHeaderTexture(); @@ -550,7 +595,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { height, totalHeaderHeight, effectiveCols, - resizedCol + resizedCol, + freezeRightColumns ); } @@ -602,6 +648,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -626,6 +673,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { effectiveCols, mappedColumns, height, + width, totalHeaderHeight, translateX, translateY, @@ -639,6 +687,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { isFocused, drawFocus, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -675,6 +724,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { selection.rows, disabledRows, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -723,7 +773,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { focusRedraw?.(); if (isResizing && resizeIndicator !== "none") { - walkColumns(effectiveCols, 0, translateX, 0, totalHeaderHeight, (c, x) => { + walkColumns(effectiveCols, width, 0, translateX, 0, totalHeaderHeight, freezeRightColumns, (c, x) => { if (c.sourceIndex === resizeCol) { drawColumnResizeOutline( overlayCtx, @@ -756,6 +806,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { const lastRowDrawn = getLastRow( effectiveCols, height, + width, totalHeaderHeight, translateX, translateY, @@ -763,7 +814,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { rows, getRowHeight, freezeTrailingRows, - hasAppendRow + hasAppendRow, + freezeRightColumns ); imageLoader?.setWindow( @@ -774,7 +826,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { height: lastRowDrawn - cellYOffset, }, freezeColumns, - Array.from({ length: freezeTrailingRows }, (_, i) => rows - 1 - i) + Array.from({ length: freezeTrailingRows }, (_, i) => rows - 1 - i), + mappedColumns.length ); const scrollX = last !== undefined && (cellXOffset !== last.cellXOffset || translateX !== last.translateX); diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts b/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts index e00ffa4b2..e5ab19fb8 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts @@ -1,5 +1,5 @@ import { type Item, type Rectangle } from "../data-grid-types.js"; -import { type MappedGridColumn, isGroupEqual } from "./data-grid-lib.js"; +import { type MappedGridColumn, isGroupEqual, getFreezeTrailingHeight } from "./data-grid-lib.js"; export function getSkipPoint(drawRegions: readonly Rectangle[]): number | undefined { if (drawRegions.length === 0) return undefined; @@ -33,8 +33,15 @@ export function walkRowsInCol( let y = drawY; let row = startRow; const rowEnd = rows - freezeTrailingRows; + // Trailing frozen rows always occupy the bottom `getFreezeTrailingHeight(...)` pixels of the + // canvas, anchored to `height`. The scrollable loop below must stop at that boundary instead + // of at the raw `height` — otherwise, whenever `height` isn't an exact multiple of the row + // heights (the common case), ordinary rows spill into the space reserved for the frozen rows + // and can get repainted on top of them by a later damage/animation-driven redraw. + const freezeY = + freezeTrailingRows > 0 ? height - getFreezeTrailingHeight(rows, freezeTrailingRows, getRowHeight) : height; let didBreak = false; - while (y < height && row < rowEnd) { + while (y < freezeY && row < rowEnd) { const rh = getRowHeight(row); if (y + rh > skipToY && cb(y, row, rh, false, hasAppendRow && row === rows - 1) === true) { didBreak = true; @@ -60,29 +67,48 @@ export type WalkColsCallback = ( drawX: number, drawY: number, clipX: number, + clipXRight: number, startRow: number ) => boolean | void; export function walkColumns( effectiveCols: readonly MappedGridColumn[], + width: number, cellYOffset: number, translateX: number, translateY: number, totalHeaderHeight: number, + freezeTrailingColumns: number, cb: WalkColsCallback ): void { let x = 0; let clipX = 0; // this tracks the total width of sticky cols const drawY = totalHeaderHeight + translateY; - for (const c of effectiveCols) { + + let clipXRight = 0; + for (let i = effectiveCols.length - freezeTrailingColumns; i < effectiveCols.length; i++) { + clipXRight += effectiveCols[i].width; + } + + for (let i = 0; i < effectiveCols.length - freezeTrailingColumns; i++) { + const c = effectiveCols[i]; const drawX = c.sticky ? clipX : x + translateX; - if (cb(c, drawX, drawY, c.sticky ? 0 : clipX, cellYOffset) === true) { + if (cb(c, drawX, drawY, c.sticky ? 0 : clipX, clipXRight, cellYOffset) === true) { break; } x += c.width; clipX += c.sticky ? c.width : 0; } + + x = width; + for (let fc = 0; fc < freezeTrailingColumns; fc++) { + const c = effectiveCols[effectiveCols.length - 1 - fc]; + const drawX = x - c.width; + + x -= c.width; + cb(c, drawX, drawY, clipX, clipXRight, cellYOffset); + } } // this should not be item, it is [startInclusive, endInclusive] @@ -100,11 +126,20 @@ export function walkGroups( width: number, translateX: number, groupHeaderHeight: number, + freezeTrailingColumns: number, cb: WalkGroupsCallback ): void { let x = 0; let clipX = 0; - for (let index = 0; index < effectiveCols.length; index++) { + + // Pre-calculate right freeze columns total width + let widthRight = 0; + for (let i = effectiveCols.length - freezeTrailingColumns; i < effectiveCols.length; i++) { + widthRight += effectiveCols[i].width; + } + const clippedWidth = width - widthRight; + + for (let index = 0; index < effectiveCols.length - freezeTrailingColumns; index++) { const startCol = effectiveCols[index]; let end = index + 1; @@ -129,7 +164,7 @@ export function walkGroups( const t = startCol.sticky ? 0 : translateX; const localX = x + t; const delta = startCol.sticky ? 0 : Math.max(0, clipX - localX); - const w = Math.min(boxWidth - delta, width - (localX + delta)); + const w = Math.min(boxWidth - delta, clippedWidth - (localX + delta)); cb( [startCol.sourceIndex, effectiveCols[end - 1].sourceIndex], startCol.group ?? "", @@ -141,6 +176,45 @@ export function walkGroups( x += boxWidth; } + + let currentWidth = clippedWidth; + const rightStartIndex = effectiveCols.length - freezeTrailingColumns; + + for (let index = rightStartIndex; index < effectiveCols.length; index++) { + const startCol = effectiveCols[index]; + + let end = index + 1; + let boxWidth = startCol.width; + + while ( + end < effectiveCols.length && + isGroupEqual(effectiveCols[end].group, startCol.group) && + effectiveCols[end].sticky === effectiveCols[index].sticky + ) { + const endCol = effectiveCols[end]; + boxWidth += endCol.width; + end++; + index++; + if (endCol.sticky) { + clipX += endCol.width; + } + } + + const t = currentWidth + boxWidth; + const localX = t - boxWidth; + const delta = 0; + const w = boxWidth - delta; + cb( + [startCol.sourceIndex, effectiveCols[end - 1].sourceIndex], + startCol.group ?? "", + localX + delta, + 0, + w, + groupHeaderHeight + ); + + currentWidth += w; + } } export function getSpanBounds( diff --git a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts index e8f07bc1d..4e4d10866 100644 --- a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts +++ b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts @@ -7,6 +7,7 @@ import { blend, withAlpha } from "../color-parser.js"; import { hugRectToTarget, intersectRect, rectContains, splitRectIntoRegions } from "../../../common/math.js"; import { getSpanBounds, walkColumns, walkRowsInCol } from "./data-grid-render.walk.js"; import { type Highlight } from "./data-grid-render.cells.js"; +import { normalizeFreezeColumns } from "../../../common/utils.js"; export function drawHighlightRings( ctx: CanvasRenderingContext2D, @@ -17,7 +18,7 @@ export function drawHighlightRings( translateX: number, translateY: number, mappedColumns: readonly MappedGridColumn[], - freezeColumns: number, + freezeColumns: number | readonly [left: number, right: number], headerHeight: number, groupHeaderHeight: number, rowHeight: number | ((index: number) => number), @@ -27,19 +28,25 @@ export function drawHighlightRings( theme: FullTheme ): (() => void) | undefined { const highlightRegions = allHighlightRegions?.filter(x => x.style !== "no-outline"); + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); if (highlightRegions === undefined || highlightRegions.length === 0) return undefined; - const freezeLeft = getStickyWidth(mappedColumns); + const [freezeLeft, freezeRight] = getStickyWidth(mappedColumns); const freezeBottom = getFreezeTrailingHeight(rows, freezeTrailingRows, rowHeight); - const splitIndicies = [freezeColumns, 0, mappedColumns.length, rows - freezeTrailingRows] as const; - const splitLocations = [freezeLeft, 0, width, height - freezeBottom] as const; + const splitIndices = [ + freezeLeftColumns, + 0, + mappedColumns.length - freezeRightColumns, + rows - freezeTrailingRows, + ] as const; + const splitLocations = [freezeLeft, 0, width - freezeRight, height - freezeBottom] as const; const drawRects = highlightRegions.map(h => { const r = h.range; const style = h.style ?? "dashed"; - return splitRectIntoRegions(r, splitIndicies, width, height, splitLocations).map(arg => { + return splitRectIntoRegions(r, splitIndices, width, height, splitLocations).map(arg => { const rect = arg.rect; const topLeftBounds = computeBounds( rect.x, @@ -187,6 +194,7 @@ export function drawFillHandle( getRowHeight: (row: number) => number, getCellContent: (cell: Item) => InnerGridCell, freezeTrailingRows: number, + freezeTrailingColumns: number, hasAppendRow: boolean, fillHandle: FillHandle, rows: number @@ -223,12 +231,13 @@ export function drawFillHandle( walkColumns( effectiveCols, + width, cellYOffset, translateX, translateY, totalHeaderHeight, - (col, drawX, colDrawY, clipX, startRow) => { - clipX; + freezeTrailingColumns, + (col, drawX, colDrawY, clipX, _clipXRight, startRow) => { if (col.sticky && targetCol > col.sourceIndex) return; const isBeforeTarget = col.sourceIndex < targetColSpan[0]; diff --git a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts index 7e3539ee1..057672d18 100644 --- a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts +++ b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts @@ -40,7 +40,7 @@ export interface DrawGridArg { readonly translateY: number; readonly mappedColumns: readonly MappedGridColumn[]; readonly enableGroups: boolean; - readonly freezeColumns: number; + readonly freezeColumns: number | readonly [left: number, right: number]; readonly dragAndDropState: DragAndDropState | undefined; readonly theme: FullTheme; readonly headerHeight: number; diff --git a/packages/core/src/internal/scrolling-data-grid/scrolling-data-grid.tsx b/packages/core/src/internal/scrolling-data-grid/scrolling-data-grid.tsx index dae1ae92b..6402a3208 100644 --- a/packages/core/src/internal/scrolling-data-grid/scrolling-data-grid.tsx +++ b/packages/core/src/internal/scrolling-data-grid/scrolling-data-grid.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import DataGridDnd, { type DataGridDndProps } from "../data-grid-dnd/data-grid-dnd.js"; import type { Rectangle } from "../data-grid/data-grid-types.js"; import { InfiniteScroller } from "./infinite-scroller.js"; +import { normalizeFreezeColumns } from "../../common/utils.js"; type Props = Omit; @@ -102,6 +103,8 @@ const GridScroller: React.FunctionComponent = p => { const lastY = React.useRef(); const lastSize = React.useRef(); + const [freezeLeftColumns] = normalizeFreezeColumns(freezeColumns); + const width = nonGrowWidth + Math.max(0, overscrollX ?? 0); let height = enableGroups ? headerHeight + groupHeaderHeight : headerHeight; @@ -130,7 +133,7 @@ const GridScroller: React.FunctionComponent = p => { args.x = args.x < 0 ? 0 : args.x; let stickyColWidth = 0; - for (let i = 0; i < freezeColumns; i++) { + for (let i = 0; i < freezeLeftColumns; i++) { stickyColWidth += columns[i].width; } @@ -219,25 +222,13 @@ const GridScroller: React.FunctionComponent = p => { args.width !== lastSize.current?.[0] || args.height !== lastSize.current?.[1] ) { - onVisibleRegionChanged?.( - { - x: cellX, - y: cellY, - width: cellRight - cellX, - height: cellBottom - cellY, - }, - args.width, - args.height, - args.paddingRight ?? 0, - tx, - ty - ); + onVisibleRegionChanged?.(rect, args.width, args.height, args.paddingRight ?? 0, tx, ty); last.current = rect; lastX.current = tx; lastY.current = ty; lastSize.current = [args.width, args.height]; } - }, [columns, rowHeight, rows, onVisibleRegionChanged, freezeColumns, smoothScrollX, smoothScrollY]); + }, [columns, rowHeight, rows, onVisibleRegionChanged, freezeLeftColumns, smoothScrollX, smoothScrollY]); const onScrollUpdate = React.useCallback( (args: Rectangle & { paddingRight: number }) => { diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 4a0d97369..83980c378 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -705,6 +705,34 @@ describe("data-editor", () => { expect(spy).toHaveBeenCalledWith([1, 1], expect.anything()); }); + test("Emits activated event when typing on a frozen column outside the tracked scroll region", async () => { + const spy = vi.fn(); + + vi.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + const [x, y] = getCellCenterPositionForDefaultGrid([1, 1]); + sendClick(canvas, { + clientX: x, // Col B, frozen (freezeColumns=2 covers cols 0 and 1) + clientY: y, + }); + + fireEvent.keyDown(canvas, { + key: "A", + }); + + act(() => { + vi.runAllTimers(); + }); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith([1, 1], expect.anything()); + }); + test("keyDown and keyUp events include the cell location", async () => { let keyDownEvent: GridKeyEventArgs | undefined; let keyUpEvent: GridKeyEventArgs | undefined; @@ -1149,6 +1177,78 @@ describe("data-editor", () => { expect(spy).toHaveBeenCalledWith(expect.objectContaining({ location: [1, 1] })); }); + test("opens context menu with Shift+F10 when cell is selected", async () => { + const spy = vi.fn(); + + vi.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + sendClick(canvas, { + clientX: 300, + clientY: 84, + }); + + fireEvent.keyDown(canvas, { + key: "F10", + keyCode: 121, + shiftKey: true, + }); + + expect(spy).toHaveBeenCalledWith([1, 1], expect.anything()); + + const eventArgs = spy.mock.calls[0][1]; + expect(eventArgs).toMatchObject({ + kind: "cell", + shiftKey: true, + location: [1, 1], + bounds: expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + localEventX: eventArgs.bounds.width / 2, + localEventY: eventArgs.bounds.height / 2, + }); + + }); + + test("does not open context menu with Shift+F10 when no cells are selected", async () => { + const spy = vi.fn(); + + vi.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + // Press Shift+F10 without selecting any cell + fireEvent.keyDown(canvas, { + key: "F10", + keyCode: 121, + shiftKey: true, + }); + + expect(spy).not.toHaveBeenCalled(); + }); + test("Delete cell", async () => { const spy = vi.fn(); @@ -2273,6 +2373,53 @@ describe("data-editor", () => { ); }); + + test("Freeze area reported with right freeze included", async () => { + const spy = vi.fn(); + vi.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + + expect(spy).toBeCalledWith( + expect.objectContaining({ + height: 32, + width: 9, + x: 2, + y: 0, + }), + 0, + 0, + expect.objectContaining({ + freezeRegion: { + height: 32, + width: 2, + x: 0, + y: 0, + }, + freezeRegions: [ + { + height: 32, + width: 2, + x: 0, + y: 0, + }, + { + height: 32, + width: 2, + x: 9, + y: 0, + }, + ], + selected: undefined, + }) + ); + }); + test("Search close", async () => { const spy = vi.fn(); vi.useFakeTimers(); @@ -2689,7 +2836,10 @@ describe("data-editor", () => { } ); prep(); - expect(spy).toHaveBeenCalledTimes(31); // Math.ceil((height - headerHeight) / rowHeight) + // basicProps sets a sticky trailingRowOptions row, which reserves one rowHeight of space + // at the bottom of the canvas for that frozen row. The scrollable rows above it stop at + // that boundary rather than at the raw canvas height. + expect(spy).toHaveBeenCalledTimes(30); // Math.ceil((height - rowHeight - headerHeight) / rowHeight) }); test("onCellsEdited blocks onCellEdited", async () => { @@ -3830,6 +3980,53 @@ describe("data-editor", () => { }); }); + test("Span in frozen trailing row draws full-width content once (no per-column overpaint)", async () => { + // Regression: a spanning cell in a frozen trailing row must be painted once at full width + // by the first column that hits it. The previous behavior repainted each column's own fill + // over the band (to cover scrolling-row overflow into the freeze band), which overpainted + // the wide span content and truncated it. Scrolling rows are now kept out of the freeze + // band instead, so the single wide fill survives and no narrow per-column fill lands there. + vi.useFakeTimers(); + + const rowHeight = basicProps.rowHeight as number; + const spannedRow = basicProps.rows - 1; + const getCellContent: (typeof basicProps)["getCellContent"] = c => { + if (c[1] === spannedRow) { + return { + ...basicProps.getCellContent([0, spannedRow]), + span: [0, basicProps.columns.length - 1] as const, + }; + } + return basicProps.getCellContent(c); + }; + + render( + , + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas") as HTMLCanvasElement; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + const frozenY = canvas.height - rowHeight; + const fillRectMock = ctx.fillRect as unknown as Mock; + // Full-redraw row fills at the frozen band. h === rowHeight distinguishes them from the + // damage-path (inset) fills and from cell-decoration draws. + const bandFills = fillRectMock.mock.calls.filter( + ([, y, , h]) => typeof y === "number" && Math.abs(y - frozenY) <= 2 && h === rowHeight + ); + + // The span is filled once across the whole grid, far wider than any single column. + expect(bandFills.some(([, , w]) => typeof w === "number" && w > 500)).toBe(true); + // No column repaints its own narrow slice over the band — that overpaint truncated the span. + expect(bandFills.some(([, , w]) => typeof w === "number" && w < 300)).toBe(false); + }); + test("Imperative scrollTo false fire", async () => { vi.useFakeTimers(); const ref = React.createRef(); diff --git a/packages/core/test/data-grid.test.tsx b/packages/core/test/data-grid.test.tsx index 33f3c86c2..0036fafdd 100644 --- a/packages/core/test/data-grid.test.tsx +++ b/packages/core/test/data-grid.test.tsx @@ -395,4 +395,71 @@ describe("data-grid", () => { false ); }); + + test("Freeze column simple check with trailing", () => { + const spy = vi.fn(); + + const basicPropsWithMoreColumns = { + ...basicProps, + columns: [ + ...basicProps.columns, + { + title: "F", + width: 150, + }, + { + title: "G", + width: 150, + }, + { + title: "H", + width: 150, + }, + { + title: "I", + width: 150, + }, + { + title: "J", + width: 150, + }, + { + title: "K", + width: 150, + }, + { + title: "L", + width: 150, + }, + ], + }; + + render(); + + + fireEvent.pointerDown(screen.getByTestId(dataGridCanvasId), { + clientX: 950, // Col A + clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) + }); + + fireEvent.pointerUp(screen.getByTestId(dataGridCanvasId), { + clientX: 950, // Col A + clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) + }); + + fireEvent.click(screen.getByTestId(dataGridCanvasId), { + clientX: 950, // Col A + clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + location: [11, 5], + kind: "cell", + localEventX: 100, + localEventY: 16, + }), + false + ); + }); }); diff --git a/packages/core/test/image-window-loader.test.ts b/packages/core/test/image-window-loader.test.ts index 398d0fd33..2abd120ab 100644 --- a/packages/core/test/image-window-loader.test.ts +++ b/packages/core/test/image-window-loader.test.ts @@ -19,13 +19,28 @@ describe("ImageWindowLoaderImpl", () => { }; const freezeCols = 5; - loader.setWindow(newWindow, freezeCols, []); + loader.setWindow(newWindow, freezeCols, [], 10); // Assuming you modify your class to expose `visibleWindow` and `freezeCols` for testing expect(loader.visibleWindow).toEqual(newWindow); expect(loader.freezeCols).toBe(freezeCols); }); + it("should set the new columnsLength", () => { + const newWindow = { + x: 10, + y: 10, + width: 100, + height: 100, + }; + const freezeCols = 5; + const columnsLength = 10; + + loader.setWindow(newWindow, freezeCols, [], columnsLength); + + expect(loader.columnsLength).toBe(columnsLength); + }); + it("should call clearOutOfWindow() if the window or freezeCols changes", () => { const spyClearOutOfWindow = vi.spyOn(loader, "clearOutOfWindow" as any); // Private method, so using 'as any' @@ -44,13 +59,13 @@ describe("ImageWindowLoaderImpl", () => { const freezeCols1 = 5; const freezeCols2 = 10; - loader.setWindow(window1, freezeCols1, []); + loader.setWindow(window1, freezeCols1, [], 10); expect(spyClearOutOfWindow).toHaveBeenCalledTimes(1); - loader.setWindow(window2, freezeCols1, []); + loader.setWindow(window2, freezeCols1, [], 10); expect(spyClearOutOfWindow).toHaveBeenCalledTimes(2); - loader.setWindow(window2, freezeCols2, []); + loader.setWindow(window2, freezeCols2, [], 10); expect(spyClearOutOfWindow).toHaveBeenCalledTimes(3); // Cleanup @@ -68,8 +83,8 @@ describe("ImageWindowLoaderImpl", () => { }; const freezeCols = 5; - loader.setWindow(newWindow, freezeCols, []); - loader.setWindow(newWindow, freezeCols, []); + loader.setWindow(newWindow, freezeCols, [], 10); + loader.setWindow(newWindow, freezeCols, [], 10); expect(spyClearOutOfWindow).toHaveBeenCalledTimes(1); diff --git a/packages/core/test/render-state-provider.test.ts b/packages/core/test/render-state-provider.test.ts index 82e772bb1..634fa48ce 100644 --- a/packages/core/test/render-state-provider.test.ts +++ b/packages/core/test/render-state-provider.test.ts @@ -78,7 +78,7 @@ describe("Data Grid Utility Functions", () => { it("should update visible window and freeze columns correctly", () => { renderStateProvider.setValue([0, 30], "state"); renderStateProvider.setValue([1, 0], "state"); - renderStateProvider.setWindow(testRectangle, 1, []); + renderStateProvider.setWindow(testRectangle, 1, [], 10); expect(renderStateProvider.getValue([0, 30])).to.equal("state"); expect(renderStateProvider.getValue([1, 0])).to.equal(undefined); }); diff --git a/packages/source/src/use-collapsing-groups.ts b/packages/source/src/use-collapsing-groups.ts index 3b748a506..6a0a245ac 100644 --- a/packages/source/src/use-collapsing-groups.ts +++ b/packages/source/src/use-collapsing-groups.ts @@ -25,13 +25,16 @@ export function useCollapsingGroups(props: Props): Result { theme, } = props; + const freezeColumnsLeft = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeColumnsRight = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const gridSelection = gridSelectionIn ?? gridSelectionInner; const spans = React.useMemo(() => { const result: [number, number][] = []; let current: [number, number] = [-1, -1]; let lastGroup: string | undefined; - for (let i = freezeColumns; i < columnsIn.length; i++) { + for (let i = freezeColumnsLeft; i < columnsIn.length - freezeColumnsRight; i++) { const c = columnsIn[i]; const group = c.group ?? ""; const isCollapsed = collapsed.includes(group); @@ -53,7 +56,7 @@ export function useCollapsingGroups(props: Props): Result { } if (current[0] !== -1) result.push(current); return result; - }, [collapsed, columnsIn, freezeColumns]); + }, [collapsed, columnsIn, freezeColumnsLeft, freezeColumnsRight]); const columns = React.useMemo(() => { if (spans.length === 0) return columnsIn;