From 321aeb12a1a10428d83767f8b910132960741970 Mon Sep 17 00:00:00 2001 From: Rahul Yadav <52163880+rahulyadav5524@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:46:46 +0530 Subject: [PATCH 01/25] Update release.js.yml --- .github/workflows/release.js.yml | 72 ++++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.js.yml b/.github/workflows/release.js.yml index 4924ac7b0..e49555160 100644 --- a/.github/workflows/release.js.yml +++ b/.github/workflows/release.js.yml @@ -28,39 +28,39 @@ jobs: token: ${{ secrets.NPM_TOKEN }} access: public package: ./packages/core/package.json - publish-cells: - defaults: - run: - working-directory: ./packages/cells - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.10.0 - - name: Bootstrap - run: npm install && npm run build - working-directory: . - - uses: JS-DevTools/npm-publish@v1 - with: - token: ${{ secrets.NPM_TOKEN }} - access: public - package: ./packages/cells/package.json - publish-source: - defaults: - run: - working-directory: ./packages/source - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.10.0 - - name: Bootstrap - run: npm install && npm run build - working-directory: . - - uses: JS-DevTools/npm-publish@v1 - with: - token: ${{ secrets.NPM_TOKEN }} - access: public - package: ./packages/source/package.json + # publish-cells: + # defaults: + # run: + # working-directory: ./packages/cells + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-node@v4 + # with: + # node-version: 20.10.0 + # - name: Bootstrap + # run: npm install && npm run build + # working-directory: . + # - uses: JS-DevTools/npm-publish@v1 + # with: + # token: ${{ secrets.NPM_TOKEN }} + # access: public + # package: ./packages/cells/package.json + # publish-source: + # defaults: + # run: + # working-directory: ./packages/source + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-node@v4 + # with: + # node-version: 20.10.0 + # - name: Bootstrap + # run: npm install && npm run build + # working-directory: . + # - uses: JS-DevTools/npm-publish@v1 + # with: + # token: ${{ secrets.NPM_TOKEN }} + # access: public + # package: ./packages/source/package.json From 970e725506d322bc90607a97f301ac1869abff78 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Mon, 1 Jul 2024 17:14:26 +0530 Subject: [PATCH 02/25] Added feature to right side freeze columnj --- packages/core/src/data-editor/data-editor.tsx | 69 ++++++++++---- .../docs/examples/freeze-columns.stories.tsx | 16 +++- .../core/src/internal/data-grid/data-grid.tsx | 92 +++++++++++++++---- .../image-window-loader-interface.ts | 2 +- .../data-grid/render/data-grid-lib.ts | 81 +++++++++++----- .../data-grid/render/data-grid-render.blit.ts | 52 ++++++----- .../render/data-grid-render.cells.ts | 4 + .../render/data-grid-render.header.ts | 5 +- .../render/data-grid-render.lines.ts | 38 +++++++- .../data-grid/render/data-grid-render.ts | 46 ++++++++-- .../data-grid/render/data-grid-render.walk.ts | 15 ++- .../render/data-grid.render.rings.ts | 13 ++- .../data-grid/render/draw-grid-arg.ts | 2 +- .../scrolling-data-grid.tsx | 14 ++- 14 files changed, 338 insertions(+), 111 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index b86721f9e..b5ee2de0c 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -923,6 +923,9 @@ const DataEditorImpl: React.ForwardRefRenderFunction { if (typeof window === "undefined") return { fontSize: "16px" }; return window.getComputedStyle(document.documentElement); @@ -1573,9 +1576,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction= mangledCols.length - freezeRightColumns; i--) { + frozenRightWidth += columns[i].width; } let trailingRowHeight = 0; const freezeTrailingRowsEffective = freezeTrailingRows + (lastRowSticky ? 1 : 0); @@ -1588,8 +1595,8 @@ 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 = (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..45a175116 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -72,7 +72,7 @@ export interface DataGridProps { readonly accessibilityHeight: number; - readonly freezeColumns: number; + readonly freezeColumns: number | [left: number, right: number]; readonly freezeTrailingRows: number; readonly hasAppendRow: boolean; readonly firstColAccessible: boolean; @@ -403,7 +403,9 @@ 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 = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + 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 +453,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] ); @@ -478,7 +480,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, translateX, translateY, rows, - freezeColumns, + freezeLeftColumns, freezeTrailingRows, mappedColumns, rowHeight @@ -506,7 +508,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, translateX, translateY, rows, - freezeColumns, + freezeLeftColumns, freezeTrailingRows, mappedColumns, rowHeight, @@ -526,7 +528,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 +554,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 @@ -723,6 +732,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, fillHandle, selection, totalHeaderHeight, + freezeColumns, ] ); @@ -1737,7 +1747,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 +1888,59 @@ 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); + + const opacityXRight = + freezeRightColumns === 0 || !fixedShadowX + ? 0 + : cellXOffset + width < columns.length - freezeRightColumns + ? 1 + : clamp((translateX - (columns.length - freezeRightColumns - width) * 32) / 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 +1949,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..6495a1ca3 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,7 @@ 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 | [left: number, right: number], freezeRows: 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..8fd5bb604 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 @@ -17,12 +17,16 @@ 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 | [left: number, right: number] ): readonly MappedGridColumn[] { + const freezeColumnsLeft = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeColumnsRight = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + return React.useMemo( () => columns.map( @@ -35,7 +39,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 +56,7 @@ export function useMappedColumns( headerRowMarkerDisabled: c.headerRowMarkerDisabled, }) ), - [columns, freezeColumns] + [columns, freezeColumnsLeft, freezeColumnsRight] ); } @@ -174,16 +180,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 +221,7 @@ export function getEffectiveColumns( columns: readonly MappedGridColumn[], cellXOffset: number, width: number, + freezeColumns: number | [left: number, right: number], dndState?: { src: number; dest: number; @@ -214,14 +230,14 @@ export function getEffectiveColumns( ): readonly MappedGridColumn[] { const mappedCols = remapForDnDState(columns, dndState); + const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + 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; @@ -242,22 +258,42 @@ export function getEffectiveColumns( } } + for (let i = mappedCols.length - freezeRightColumns; i < mappedCols.length; i++) { + sticky.push(mappedCols[i]); + } + return sticky; } export function getColumnIndexForX( targetX: number, effectiveColumns: readonly MappedGridColumn[], + freezeColumns: number | [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 +804,7 @@ export function computeBounds( translateX: number, translateY: number, rows: number, - freezeColumns: number, + freezeColumns: number | [left: number, right: number], freezeTrailingRows: number, mappedColumns: readonly MappedGridColumn[], rowHeight: number | ((index: number) => number) @@ -780,16 +816,19 @@ export function computeBounds( height: 0, }; + const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + if (col >= mappedColumns.length || row >= rows || row < -2 || col < 0) { return result; } const headerHeight = totalHeaderHeight - groupHeaderHeight; - if (col >= freezeColumns) { + if (col >= freezeLeftColumns) { const dir = cellXOffset > col ? -1 : 1; - const freezeWidth = getStickyWidth(mappedColumns); - result.x += freezeWidth + translateX; + const [freezeLeftWidth, freezeRightWidth] = getStickyWidth(mappedColumns); + result.x += freezeLeftWidth + translateX; for (let i = cellXOffset; i !== col; i += dir) { result.x += mappedColumns[dir === 1 ? i : i - 1].width * dir; } @@ -832,8 +871,8 @@ export function computeBounds( end++; } if (!sticky) { - const freezeWidth = getStickyWidth(mappedColumns); - const clip = result.x - freezeWidth; + const [freezeLeftWidth, freezeRightWidth] = 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..a6c9806d2 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,22 +128,22 @@ 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({ @@ -157,14 +157,14 @@ 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); } @@ -200,7 +200,8 @@ export function blitResizedCol( height: number, totalHeaderHeight: number, effectiveCols: readonly MappedGridColumn[], - resizedIndex: number + resizedIndex: number, + freezeTrailingColumns: number ) { const drawRegions: Rectangle[] = []; @@ -215,18 +216,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..fed1f21f4 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,10 +126,12 @@ export function drawCells( walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, + freezeTrailingColumns, (c, drawX, colDrawStartY, clipX, startRow) => { const diff = Math.max(0, clipX - drawX); 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..daf594b85 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,7 +55,7 @@ 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) => { + walkColumns(effectiveCols, width, 0, translateX, 0, totalHeaderHeight, freezeTrailingColumns, (c, x, _y, clipX) => { if (damage !== undefined && !damage.has([c.sourceIndex, -1])) return; const diff = Math.max(0, clipX - x); ctx.save(); 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..c1a58191e 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,10 +42,12 @@ export function drawBlanks( walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, + freezeTrailingColumns, (c, drawX, colDrawY, clipX, startRow) => { if (c !== effectiveColumns[effectiveColumns.length - 1]) return; drawX += c.width; @@ -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); @@ -332,6 +344,24 @@ export function drawGridLines( } } + // let rightX = 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 = c.sticky ? rightX : rightX + translateX; + // 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..b1592eb44 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 @@ -33,6 +33,7 @@ function clipHeaderDamage( translateX: number, translateY: number, cellYOffset: number, + freezeTrailingColumns: number, damage: CellSet | undefined ): void { if (damage === undefined || damage.size === 0) return; @@ -53,10 +54,12 @@ function clipHeaderDamage( walkColumns( effectiveColumns, + width, cellYOffset, translateX, translateY, totalHeaderHeight, + freezeTrailingColumns, (c, drawX, _colDrawY, clipX) => { const diff = Math.max(0, clipX - drawX); @@ -73,6 +76,7 @@ function clipHeaderDamage( function getLastRow( effectiveColumns: readonly MappedGridColumn[], height: number, + width: number, totalHeaderHeight: number, translateX: number, translateY: number, @@ -80,15 +84,18 @@ 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, + freezeTrailingColumns, (_c, __drawX, colDrawY, _clipX, startRow) => { walkRowsInCol( startRow, @@ -171,6 +178,9 @@ 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 = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + // 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 +263,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 +304,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getGroupDetails, damage, drawHeaderCallback, - touchMode + touchMode, + freezeRightColumns ); drawGridLines( @@ -357,6 +375,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -383,13 +402,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, }, { @@ -407,6 +426,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { effectiveCols, mappedColumns, height, + width, totalHeaderHeight, translateX, translateY, @@ -420,6 +440,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { isFocused, drawFocus, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -463,6 +484,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -491,6 +513,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { translateX, translateY, cellYOffset, + freezeRightColumns, damage ); drawHeaderTexture(); @@ -550,7 +573,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { height, totalHeaderHeight, effectiveCols, - resizedCol + resizedCol, + freezeRightColumns ); } @@ -602,6 +626,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { getRowHeight, getCellContent, freezeTrailingRows, + freezeRightColumns, hasAppendRow, fillHandle, rows @@ -626,6 +651,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { effectiveCols, mappedColumns, height, + width, totalHeaderHeight, translateX, translateY, @@ -639,6 +665,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { isFocused, drawFocus, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -675,6 +702,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { selection.rows, disabledRows, freezeTrailingRows, + freezeRightColumns, hasAppendRow, drawRegions, damage, @@ -723,7 +751,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 +784,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { const lastRowDrawn = getLastRow( effectiveCols, height, + width, totalHeaderHeight, translateX, translateY, @@ -763,7 +792,8 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { rows, getRowHeight, freezeTrailingRows, - hasAppendRow + hasAppendRow, + freezeRightColumns ); imageLoader?.setWindow( 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..d56cba716 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 @@ -65,16 +65,20 @@ export type WalkColsCallback = ( 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) { + + 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) { break; @@ -83,6 +87,15 @@ export function walkColumns( 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, cellYOffset); + } } // this should not be item, it is [startInclusive, endInclusive] 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..31c0be305 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 @@ -17,7 +17,7 @@ export function drawHighlightRings( translateX: number, translateY: number, mappedColumns: readonly MappedGridColumn[], - freezeColumns: number, + freezeColumns: number | [left: number, right: number], headerHeight: number, groupHeaderHeight: number, rowHeight: number | ((index: number) => number), @@ -27,19 +27,21 @@ export function drawHighlightRings( theme: FullTheme ): (() => void) | undefined { const highlightRegions = allHighlightRegions?.filter(x => x.style !== "no-outline"); + const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; 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 splitIndices = [freezeLeftColumns, 0, mappedColumns.length, rows - freezeTrailingRows] as const; const splitLocations = [freezeLeft, 0, width, 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 +189,7 @@ export function drawFillHandle( getRowHeight: (row: number) => number, getCellContent: (cell: Item) => InnerGridCell, freezeTrailingRows: number, + freezeTrailingColumns: number, hasAppendRow: boolean, fillHandle: FillHandle, rows: number @@ -223,10 +226,12 @@ export function drawFillHandle( walkColumns( effectiveCols, + width, cellYOffset, translateX, translateY, totalHeaderHeight, + freezeTrailingColumns, (col, drawX, colDrawY, clipX, startRow) => { clipX; if (col.sticky && targetCol > col.sourceIndex) return; 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..b352d7fe2 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 | [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..7661085bf 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 @@ -102,6 +102,9 @@ const GridScroller: React.FunctionComponent = p => { const lastY = React.useRef(); const lastSize = React.useRef(); + const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const freezeRightColumns = typeof freezeColumns === "number"? 0 : freezeColumns[1]; + 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; } @@ -220,12 +223,7 @@ const GridScroller: React.FunctionComponent = p => { args.height !== lastSize.current?.[1] ) { onVisibleRegionChanged?.( - { - x: cellX, - y: cellY, - width: cellRight - cellX, - height: cellBottom - cellY, - }, + rect, args.width, args.height, args.paddingRight ?? 0, @@ -237,7 +235,7 @@ const GridScroller: React.FunctionComponent = p => { 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 }) => { From dbc629dd66564ba3b3dc158b34b475ee97680b44 Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Mon, 1 Jul 2024 17:17:09 +0530 Subject: [PATCH 03/25] revert workflow file --- .github/workflows/release.js.yml | 72 ++++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.js.yml b/.github/workflows/release.js.yml index e49555160..4924ac7b0 100644 --- a/.github/workflows/release.js.yml +++ b/.github/workflows/release.js.yml @@ -28,39 +28,39 @@ jobs: token: ${{ secrets.NPM_TOKEN }} access: public package: ./packages/core/package.json - # publish-cells: - # defaults: - # run: - # working-directory: ./packages/cells - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-node@v4 - # with: - # node-version: 20.10.0 - # - name: Bootstrap - # run: npm install && npm run build - # working-directory: . - # - uses: JS-DevTools/npm-publish@v1 - # with: - # token: ${{ secrets.NPM_TOKEN }} - # access: public - # package: ./packages/cells/package.json - # publish-source: - # defaults: - # run: - # working-directory: ./packages/source - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-node@v4 - # with: - # node-version: 20.10.0 - # - name: Bootstrap - # run: npm install && npm run build - # working-directory: . - # - uses: JS-DevTools/npm-publish@v1 - # with: - # token: ${{ secrets.NPM_TOKEN }} - # access: public - # package: ./packages/source/package.json + publish-cells: + defaults: + run: + working-directory: ./packages/cells + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.10.0 + - name: Bootstrap + run: npm install && npm run build + working-directory: . + - uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + package: ./packages/cells/package.json + publish-source: + defaults: + run: + working-directory: ./packages/source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.10.0 + - name: Bootstrap + run: npm install && npm run build + working-directory: . + - uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + package: ./packages/source/package.json From 73a7e6c473af8043b1faf898cdb80a4f1ff663cf Mon Sep 17 00:00:00 2001 From: Rahul Yadav Date: Tue, 2 Jul 2024 15:27:12 +0530 Subject: [PATCH 04/25] fixed selection issue and highlight fixes --- packages/core/src/data-editor/data-editor.tsx | 5 +-- .../core/src/internal/data-grid/data-grid.tsx | 7 +++- .../data-grid/render/data-grid-lib.ts | 16 ++++++--- .../data-grid/render/data-grid-render.blit.ts | 15 ++++++-- .../render/data-grid-render.cells.ts | 2 +- .../render/data-grid-render.header.ts | 5 +++ .../render/data-grid-render.lines.ts | 35 ++++++++++--------- .../data-grid/render/data-grid-render.ts | 12 +++++++ .../render/data-grid.render.rings.ts | 9 +++-- .../scrolling-data-grid.tsx | 10 +----- 10 files changed, 77 insertions(+), 39 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index b5ee2de0c..5d83b0eae 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -923,7 +923,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { @@ -1595,7 +1595,8 @@ const DataEditorImpl: 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); @@ -733,6 +737,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, selection, totalHeaderHeight, freezeColumns, + freezeRightColumns, ] ); 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 8fd5bb604..5b075231f 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 @@ -279,7 +279,7 @@ export function getColumnIndexForX( const colIdx = effectiveColumns.length - 1 - fc; const col = effectiveColumns[colIdx]; y -= col.width; - if (targetX <= y) { + if (targetX >= y) { return col.sourceIndex; } } @@ -818,6 +818,7 @@ export function computeBounds( const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const column = mappedColumns[col]; if (col >= mappedColumns.length || row >= rows || row < -2 || col < 0) { return result; @@ -825,17 +826,22 @@ export function computeBounds( const headerHeight = totalHeaderHeight - groupHeaderHeight; - if (col >= freezeLeftColumns) { + if (col >= freezeLeftColumns && col < mappedColumns.length - freezeRightColumns) { const dir = cellXOffset > col ? -1 : 1; - const [freezeLeftWidth, freezeRightWidth] = getStickyWidth(mappedColumns); + 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; @@ -871,7 +877,7 @@ export function computeBounds( end++; } if (!sticky) { - const [freezeLeftWidth, freezeRightWidth] = getStickyWidth(mappedColumns); + const [freezeLeftWidth] = getStickyWidth(mappedColumns); const clip = result.x - freezeLeftWidth; if (clip < 0) { result.x -= 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 a6c9806d2..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 @@ -147,9 +147,9 @@ export function blitLastFrame( args.dw = blitWidth * dpr; drawRegions.push({ - x: width + deltaX, + x: width + deltaX - stickyRightWidth, y: 0, - width: -deltaX, + width: -deltaX + stickyRightWidth, height: height, }); } @@ -168,6 +168,17 @@ export function blitLastFrame( 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 && 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 fed1f21f4..a5cd1a46d 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 @@ -299,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); } 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 daf594b85..f1b2c62c0 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 @@ -69,6 +69,11 @@ export function drawGridHeaders( ? outerTheme : mergeAndRealizeTheme(outerTheme, groupTheme, c.themeOverride); + if (c.sticky) { + ctx.fillStyle = theme.bgHeader; + ctx.fill(); + } + if (theme.bgHeader !== outerTheme.bgHeader) { ctx.fillStyle = theme.bgHeader; ctx.fill(); 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 c1a58191e..a4e0fb8aa 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 @@ -331,6 +331,7 @@ export function drawGridLines( for (let index = 0; index < effectiveCols.length; index++) { const c = effectiveCols[index]; if (c.width === 0) continue; + if (c.sticky && c.stickyPosition !== "left") break; x += c.width; const tx = c.sticky ? x : x + translateX; if (tx >= minX && tx <= maxX && verticalBorder(index + 1)) { @@ -344,23 +345,23 @@ export function drawGridLines( } } - // let rightX = 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 = c.sticky ? rightX : rightX + translateX; - // 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 rightX = width + 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++) { 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 b1592eb44..853e415e2 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 @@ -418,6 +418,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) => { 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 31c0be305..766319b38 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 @@ -34,8 +34,13 @@ export function drawHighlightRings( const [freezeLeft, freezeRight] = getStickyWidth(mappedColumns); const freezeBottom = getFreezeTrailingHeight(rows, freezeTrailingRows, rowHeight); - const splitIndices = [freezeLeftColumns, 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; 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 7661085bf..0ba089ac1 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 @@ -103,7 +103,6 @@ const GridScroller: React.FunctionComponent = p => { const lastSize = React.useRef(); const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number"? 0 : freezeColumns[1]; const width = nonGrowWidth + Math.max(0, overscrollX ?? 0); @@ -222,14 +221,7 @@ const GridScroller: React.FunctionComponent = p => { args.width !== lastSize.current?.[0] || args.height !== lastSize.current?.[1] ) { - onVisibleRegionChanged?.( - rect, - 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; From fb2abf534cbf726bbfc0fe203629eef550446d89 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Sun, 22 Jun 2025 17:16:26 +0400 Subject: [PATCH 05/25] freeze right columns: calculations, hovers, groupings and shadows --- .../core/src/internal/data-grid/data-grid.tsx | 23 +- .../data-grid/render/data-grid-lib.ts | 17 +- .../render/data-grid-render.cells.ts | 14 +- .../render/data-grid-render.header.ts | 367 ++++++++++-------- .../render/data-grid-render.lines.ts | 4 +- .../data-grid/render/data-grid-render.ts | 71 ++-- .../data-grid/render/data-grid-render.walk.ts | 50 ++- .../render/data-grid.render.rings.ts | 35 +- packages/core/test/data-editor.test.tsx | 47 +++ packages/core/test/data-grid.test.tsx | 67 ++++ packages/source/src/use-collapsing-groups.ts | 11 +- 11 files changed, 465 insertions(+), 241 deletions(-) diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index 6f2c1f30c..2e2b93f02 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -480,7 +480,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, translateX, translateY, rows, - freezeLeftColumns, + freezeColumns, freezeTrailingRows, mappedColumns, rowHeight @@ -508,7 +508,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, translateX, translateY, rows, - freezeLeftColumns, + freezeColumns, freezeTrailingRows, mappedColumns, rowHeight, @@ -1905,12 +1905,27 @@ const DataGrid: React.ForwardRefRenderFunction = (p, ? 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 + width < columns.length - freezeRightColumns + : cellXOffset + + getEffectiveColumns( + mappedColumns, + cellXOffset, + width, + freezeColumns, + dragAndDropState, + translateX + ).filter(column => !column.sticky).length < + columns.length - freezeRightColumns ? 1 - : clamp((translateX - (columns.length - freezeRightColumns - width) * 32) / 100, 0, 1); + : clamp(-translateXRight / 100, 0, 1); const absoluteOffsetY = -cellYOffset * 32 + translateY; const opacityY = !fixedShadowY ? 0 : clamp(-absoluteOffsetY / 100, 0, 1); 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 5b075231f..eef07c677 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 @@ -243,6 +243,19 @@ export function getEffectiveColumns( 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; @@ -258,9 +271,7 @@ export function getEffectiveColumns( } } - for (let i = mappedCols.length - freezeRightColumns; i < mappedCols.length; i++) { - sticky.push(mappedCols[i]); - } + sticky.push(...stickyRight); return sticky; } 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 a5cd1a46d..36cb36300 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 @@ -132,12 +132,12 @@ export function drawCells( translateY, totalHeaderHeight, freezeTrailingColumns, - (c, drawX, colDrawStartY, clipX, startRow) => { + (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; @@ -555,11 +555,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 f1b2c62c0..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 @@ -55,85 +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, width, 0, translateX, 0, totalHeaderHeight, freezeTrailingColumns, (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 (c.sticky) { - ctx.fillStyle = theme.bgHeader; - ctx.fill(); - } - - 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( @@ -148,7 +170,8 @@ export function drawGridHeaders( hoverValues, verticalBorder, getGroupDetails, - damage + damage, + freezeTrailingColumns ); } } @@ -165,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 a4e0fb8aa..d142cd023 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 @@ -48,7 +48,7 @@ export function drawBlanks( translateY, totalHeaderHeight, freezeTrailingColumns, - (c, drawX, colDrawY, clipX, startRow) => { + (c, drawX, colDrawY, clipX, _clipXRight, startRow) => { if (c !== effectiveColumns[effectiveColumns.length - 1]) return; drawX += c.width; const x = Math.max(drawX, clipX); @@ -331,7 +331,7 @@ export function drawGridLines( for (let index = 0; index < effectiveCols.length; index++) { const c = effectiveCols[index]; if (c.width === 0) continue; - if (c.sticky && c.stickyPosition !== "left") break; + 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)) { 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 853e415e2..71f1c4da0 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 @@ -40,17 +40,24 @@ function clipHeaderDamage( 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, @@ -60,11 +67,11 @@ function clipHeaderDamage( translateY, totalHeaderHeight, freezeTrailingColumns, - (c, drawX, _colDrawY, clipX) => { + (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); // c.width - diff - 1; if (damage.has([c.sourceIndex, -1])) { ctx.rect(finalX, groupHeaderHeight, finalWidth, totalHeaderHeight - groupHeaderHeight); } @@ -96,7 +103,7 @@ function getLastRow( translateY, totalHeaderHeight, freezeTrailingColumns, - (_c, __drawX, colDrawY, _clipX, startRow) => { + (_c, __drawX, colDrawY, _clipX, _clipXRight, startRow) => { walkRowsInCol( startRow, colDrawY, @@ -624,25 +631,25 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { // the overdraw may have nuked out our focus ring right edge. const focusRedraw = drawFocus ? drawFillHandle( - targetCtx, - width, - height, - cellYOffset, - translateX, - translateY, - effectiveCols, - mappedColumns, - theme, - totalHeaderHeight, - selection, - getRowHeight, - getCellContent, - freezeTrailingRows, - freezeRightColumns, - hasAppendRow, - fillHandle, - rows - ) + targetCtx, + width, + height, + cellYOffset, + translateX, + translateY, + effectiveCols, + mappedColumns, + theme, + totalHeaderHeight, + selection, + getRowHeight, + getCellContent, + freezeTrailingRows, + freezeRightColumns, + hasAppendRow, + fillHandle, + rows + ) : undefined; targetCtx.fillStyle = theme.bgCell; 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 d56cba716..13ddaf128 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 @@ -60,6 +60,7 @@ export type WalkColsCallback = ( drawX: number, drawY: number, clipX: number, + clipXRight: number, startRow: number ) => boolean | void; @@ -76,11 +77,12 @@ export function walkColumns( let x = 0; let clipX = 0; // this tracks the total width of sticky cols const drawY = totalHeaderHeight + translateY; + const clipXRight = freezeTrailingColumns === 0 ? 0 : effectiveCols.slice(-freezeTrailingColumns).reduce((acc, col) => acc + col.width, 0); 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; } @@ -94,7 +96,7 @@ export function walkColumns( const drawX = x - c.width; x -= c.width; - cb(c, drawX, drawY, clipX, cellYOffset); + cb(c, drawX, drawY, clipX, clipXRight, cellYOffset); } } @@ -113,11 +115,17 @@ 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++) { + + const effectiveColsRight = freezeTrailingColumns === 0 ? [] : effectiveCols.slice(-freezeTrailingColumns); + const widthRight = effectiveColsRight.reduce((acc, col) => acc + col.width, 0); + width -= widthRight; + + for (let index = 0; index < effectiveCols.length - freezeTrailingColumns; index++) { const startCol = effectiveCols[index]; let end = index + 1; @@ -154,6 +162,42 @@ export function walkGroups( x += boxWidth; } + + for (let index = 0; index < effectiveColsRight.length; index++) { + const startCol = effectiveColsRight[index]; + + let end = index + 1; + let boxWidth = startCol.width; + + while ( + end < effectiveColsRight.length && + isGroupEqual(effectiveColsRight[end].group, startCol.group) && + effectiveColsRight[end].sticky === effectiveColsRight[index].sticky + ) { + const endCol = effectiveColsRight[end]; + boxWidth += endCol.width; + end++; + index++; + if (endCol.sticky) { + clipX += endCol.width; + } + } + + const t = width + boxWidth; + const localX = t - boxWidth; + const delta = 0; + const w = boxWidth - delta; + cb( + [startCol.sourceIndex, effectiveColsRight[end - 1].sourceIndex], + startCol.group ?? "", + localX + delta, + 0, + w, + groupHeaderHeight + ); + + width += 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 766319b38..b60d489f9 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 @@ -69,22 +69,22 @@ export function drawHighlightRings( rect.width === 1 && rect.height === 1 ? topLeftBounds : computeBounds( - rect.x + rect.width - 1, - rect.y + rect.height - 1, - width, - height, - groupHeaderHeight, - headerHeight + groupHeaderHeight, - cellXOffset, - cellYOffset, - translateX, - translateY, - rows, - freezeColumns, - freezeTrailingRows, - mappedColumns, - rowHeight - ); + rect.x + rect.width - 1, + rect.y + rect.height - 1, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + freezeTrailingRows, + mappedColumns, + rowHeight + ); if (rect.x + rect.width >= mappedColumns.length) { bottomRightBounds.width -= 1; @@ -237,8 +237,7 @@ export function drawFillHandle( translateY, totalHeaderHeight, freezeTrailingColumns, - (col, drawX, colDrawY, clipX, startRow) => { - clipX; + (col, drawX, colDrawY, clipX, _clipXRight, startRow) => { if (col.sticky && targetCol > col.sourceIndex) return; const isBeforeTarget = col.sourceIndex < targetColSpan[0]; diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 4a0d97369..23b25cf37 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -2273,6 +2273,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(); diff --git a/packages/core/test/data-grid.test.tsx b/packages/core/test/data-grid.test.tsx index 33f3c86c2..4f19fa648 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.mouseDown(screen.getByTestId(dataGridCanvasId), { + clientX: 950, // Col A + clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) + }); + + fireEvent.mouseUp(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/source/src/use-collapsing-groups.ts b/packages/source/src/use-collapsing-groups.ts index 3b748a506..1b3df6d96 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 as number; 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; @@ -118,8 +121,8 @@ export function useCollapsingGroups(props: Props): Result { name: group, overrideTheme: collapsed.includes(group ?? "") ? { - bgHeader: theme.bgHeaderHasFocus, - } + bgHeader: theme.bgHeaderHasFocus, + } : undefined, }; }, From e245c1aeffd9760ce1e947de5f733a0a5ed03a1e Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Thu, 3 Jul 2025 18:09:39 +0400 Subject: [PATCH 06/25] added an inline comment explaining freezeColumnsLeft and freezeColumnsRight --- packages/core/src/internal/data-grid/render/data-grid-lib.ts | 2 ++ 1 file changed, 2 insertions(+) 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 eef07c677..ebf4e063c 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 @@ -24,6 +24,8 @@ export function useMappedColumns( columns: readonly InnerGridColumn[], freezeColumns: number | [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]; From 9033b8a748e8edba5cd2dcf32de224d3159be692 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Thu, 3 Jul 2025 18:11:04 +0400 Subject: [PATCH 07/25] added readonly flag to freezeColumns tuple prop --- .../core/src/internal/data-grid/data-grid.tsx | 2 +- .../image-window-loader-interface.ts | 6 +++- .../data-grid/render/data-grid-lib.ts | 8 ++--- .../render/data-grid.render.rings.ts | 34 +++++++++---------- .../data-grid/render/draw-grid-arg.ts | 2 +- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index 2e2b93f02..a22096427 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -72,7 +72,7 @@ export interface DataGridProps { readonly accessibilityHeight: number; - readonly freezeColumns: number | [left: number, right: number]; + readonly freezeColumns: number | readonly [left: number, right: number]; readonly freezeTrailingRows: number; readonly hasAppendRow: boolean; readonly firstColAccessible: boolean; 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 6495a1ca3..a06c1033f 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,11 @@ import type { Rectangle } from "./data-grid-types.js"; /** @category Types */ export interface ImageWindowLoader { - setWindow(newWindow: Rectangle, freezeCols: number | [left: number, right: number], freezeRows: number[]): void; + setWindow( + newWindow: Rectangle, + freezeCols: number | readonly [left: number, right: number], + freezeRows: 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 ebf4e063c..bf10cf4dd 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 @@ -22,7 +22,7 @@ export interface MappedGridColumn extends FullyDefined { export function useMappedColumns( columns: readonly InnerGridColumn[], - freezeColumns: number | [left: number, right: 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. @@ -223,7 +223,7 @@ export function getEffectiveColumns( columns: readonly MappedGridColumn[], cellXOffset: number, width: number, - freezeColumns: number | [left: number, right: number], + freezeColumns: number | readonly [left: number, right: number], dndState?: { src: number; dest: number; @@ -281,7 +281,7 @@ export function getEffectiveColumns( export function getColumnIndexForX( targetX: number, effectiveColumns: readonly MappedGridColumn[], - freezeColumns: number | [left: number, right: number], + freezeColumns: number | readonly [left: number, right: number], width: number, translateX?: number ): number { @@ -817,7 +817,7 @@ export function computeBounds( translateX: number, translateY: number, rows: number, - freezeColumns: number | [left: number, right: number], + freezeColumns: number | readonly [left: number, right: number], freezeTrailingRows: number, mappedColumns: readonly MappedGridColumn[], rowHeight: number | ((index: number) => number) 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 b60d489f9..723ad4a23 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 @@ -17,7 +17,7 @@ export function drawHighlightRings( translateX: number, translateY: number, mappedColumns: readonly MappedGridColumn[], - freezeColumns: number | [left: number, right: number], + freezeColumns: number | readonly [left: number, right: number], headerHeight: number, groupHeaderHeight: number, rowHeight: number | ((index: number) => number), @@ -69,22 +69,22 @@ export function drawHighlightRings( rect.width === 1 && rect.height === 1 ? topLeftBounds : computeBounds( - rect.x + rect.width - 1, - rect.y + rect.height - 1, - width, - height, - groupHeaderHeight, - headerHeight + groupHeaderHeight, - cellXOffset, - cellYOffset, - translateX, - translateY, - rows, - freezeColumns, - freezeTrailingRows, - mappedColumns, - rowHeight - ); + rect.x + rect.width - 1, + rect.y + rect.height - 1, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + freezeTrailingRows, + mappedColumns, + rowHeight + ); if (rect.x + rect.width >= mappedColumns.length) { bottomRightBounds.width -= 1; 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 b352d7fe2..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 | [left: number, right: number]; + readonly freezeColumns: number | readonly [left: number, right: number]; readonly dragAndDropState: DragAndDropState | undefined; readonly theme: FullTheme; readonly headerHeight: number; From 9a65fc048f8f5369c8c85af16a30e10deed9fadb Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Thu, 3 Jul 2025 18:12:54 +0400 Subject: [PATCH 08/25] fix scrollTo issue not finding a column --- packages/core/src/data-editor/data-editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 5d83b0eae..d59c76c79 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -1581,7 +1581,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction= mangledCols.length - freezeRightColumns; i--) { + for (let i = columns.length - 1; i >= columns.length - 1 - freezeRightColumns; i--) { frozenRightWidth += columns[i].width; } let trailingRowHeight = 0; From c65fe4246bb8b853ef5d58d8d83de42c54a9f9b8 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Tue, 8 Jul 2025 16:39:02 +0400 Subject: [PATCH 09/25] fix image loader doesn't take into account freezeColumns new api --- .../core/src/common/render-state-provider.ts | 15 ++++-- .../image-window-loader-interface.ts | 3 +- .../data-grid/render/data-grid-render.ts | 46 ++++++++++--------- .../core/test/image-window-loader.test.ts | 27 ++++++++--- .../core/test/render-state-provider.test.ts | 2 +- 5 files changed, 61 insertions(+), 32 deletions(-) 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/internal/data-grid/image-window-loader-interface.ts b/packages/core/src/internal/data-grid/image-window-loader-interface.ts index a06c1033f..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 @@ -6,7 +6,8 @@ export interface ImageWindowLoader { setWindow( newWindow: Rectangle, freezeCols: number | readonly [left: number, right: number], - freezeRows: 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-render.ts b/packages/core/src/internal/data-grid/render/data-grid-render.ts index 71f1c4da0..05190a69e 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 @@ -71,7 +71,10 @@ function clipHeaderDamage( const diff = Math.max(0, clipX - drawX); const finalX = drawX + diff + 1; - const finalWidth = c.stickyPosition === "right" ? c.width - diff : Math.min(c.width - diff - 1, width - drawX - clipXRight); // 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); } @@ -631,25 +634,25 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) { // the overdraw may have nuked out our focus ring right edge. const focusRedraw = drawFocus ? drawFillHandle( - targetCtx, - width, - height, - cellYOffset, - translateX, - translateY, - effectiveCols, - mappedColumns, - theme, - totalHeaderHeight, - selection, - getRowHeight, - getCellContent, - freezeTrailingRows, - freezeRightColumns, - hasAppendRow, - fillHandle, - rows - ) + targetCtx, + width, + height, + cellYOffset, + translateX, + translateY, + effectiveCols, + mappedColumns, + theme, + totalHeaderHeight, + selection, + getRowHeight, + getCellContent, + freezeTrailingRows, + freezeRightColumns, + hasAppendRow, + fillHandle, + rows + ) : undefined; targetCtx.fillStyle = theme.bgCell; @@ -823,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/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); }); From e89d0f876d724ee32664a102f3e68b13a9ddbfd8 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Tue, 15 Jul 2025 10:49:28 +0400 Subject: [PATCH 10/25] fix vertical lines calculation issue --- .../src/internal/data-grid/render/data-grid-render.lines.ts | 3 +++ 1 file changed, 3 insertions(+) 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 d142cd023..ccd598f3a 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 @@ -317,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; @@ -345,6 +347,7 @@ export function drawGridLines( } } + width = Math.min(width, effectiveWidth); let rightX = width + 0.5; for (let index = effectiveCols.length - 1; index >= 0; index--) { const c = effectiveCols[index]; From 9d0365457ebb1f974316c93807a7e3b062c4a966 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Fri, 8 Aug 2025 12:18:27 +0400 Subject: [PATCH 11/25] add utility function to extract left and right freeze, minor fixes --- packages/core/src/common/utils.tsx | 7 +++++++ packages/core/src/data-editor/data-editor.tsx | 7 +++---- packages/core/src/internal/data-grid/data-grid.tsx | 11 ++++++++--- .../src/internal/data-grid/render/data-grid-lib.ts | 8 +++----- .../src/internal/data-grid/render/data-grid-render.ts | 4 ++-- .../data-grid/render/data-grid.render.rings.ts | 4 ++-- .../scrolling-data-grid/scrolling-data-grid.tsx | 3 ++- packages/source/src/use-collapsing-groups.ts | 6 +++--- 8 files changed, 30 insertions(+), 20 deletions(-) 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.tsx b/packages/core/src/data-editor/data-editor.tsx index d59c76c79..c3b03a53b 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,8 +923,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { if (typeof window === "undefined") return { fontSize: "16px" }; @@ -1581,7 +1580,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction= columns.length - 1 - freezeRightColumns; i--) { + for (let i = columns.length - 1; i >= columns.length - freezeRightColumns; i--) { frozenRightWidth += columns[i].width; } let trailingRowHeight = 0; diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index a22096427..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"; @@ -403,8 +409,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, } = p; const translateX = p.translateX ?? 0; const translateY = p.translateY ?? 0; - const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); const cellXOffset = Math.max(freezeLeftColumns, Math.min(columns.length - 1, cellXOffsetReal)); const ref = React.useRef(null); 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 bf10cf4dd..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"; @@ -232,8 +232,7 @@ export function getEffectiveColumns( ): readonly MappedGridColumn[] { const mappedCols = remapForDnDState(columns, dndState); - const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); const sticky: MappedGridColumn[] = []; for (let i = 0; i < freezeLeftColumns; i++) { @@ -829,8 +828,7 @@ export function computeBounds( height: 0, }; - const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); const column = mappedColumns[col]; if (col >= mappedColumns.length || row >= rows || row < -2 || col < 0) { 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 05190a69e..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 @@ -188,8 +189,7 @@ 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 = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[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); 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 723ad4a23..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, @@ -27,8 +28,7 @@ export function drawHighlightRings( theme: FullTheme ): (() => void) | undefined { const highlightRegions = allHighlightRegions?.filter(x => x.style !== "no-outline"); - const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; - const freezeRightColumns = typeof freezeColumns === "number" ? 0 : freezeColumns[1]; + const [freezeLeftColumns, freezeRightColumns] = normalizeFreezeColumns(freezeColumns); if (highlightRegions === undefined || highlightRegions.length === 0) return undefined; 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 0ba089ac1..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,7 +103,7 @@ const GridScroller: React.FunctionComponent = p => { const lastY = React.useRef(); const lastSize = React.useRef(); - const freezeLeftColumns = typeof freezeColumns === "number" ? freezeColumns : freezeColumns[0]; + const [freezeLeftColumns] = normalizeFreezeColumns(freezeColumns); const width = nonGrowWidth + Math.max(0, overscrollX ?? 0); diff --git a/packages/source/src/use-collapsing-groups.ts b/packages/source/src/use-collapsing-groups.ts index 1b3df6d96..6a0a245ac 100644 --- a/packages/source/src/use-collapsing-groups.ts +++ b/packages/source/src/use-collapsing-groups.ts @@ -34,7 +34,7 @@ export function useCollapsingGroups(props: Props): Result { const result: [number, number][] = []; let current: [number, number] = [-1, -1]; let lastGroup: string | undefined; - for (let i = freezeColumnsLeft as number; i < columnsIn.length - freezeColumnsRight; i++) { + for (let i = freezeColumnsLeft; i < columnsIn.length - freezeColumnsRight; i++) { const c = columnsIn[i]; const group = c.group ?? ""; const isCollapsed = collapsed.includes(group); @@ -121,8 +121,8 @@ export function useCollapsingGroups(props: Props): Result { name: group, overrideTheme: collapsed.includes(group ?? "") ? { - bgHeader: theme.bgHeaderHasFocus, - } + bgHeader: theme.bgHeaderHasFocus, + } : undefined, }; }, From 2cc2935cea76a82983d6e86cd148d8e3c3a9421e Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Wed, 20 Aug 2025 12:23:38 +0400 Subject: [PATCH 12/25] apply minor code review impovements --- .../render/data-grid-render.lines.ts | 4 +- .../data-grid/render/data-grid-render.walk.ts | 38 ++++++++++++------- 2 files changed, 26 insertions(+), 16 deletions(-) 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 ccd598f3a..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 @@ -347,8 +347,8 @@ export function drawGridLines( } } - width = Math.min(width, effectiveWidth); - let rightX = width + 0.5; + 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; 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 13ddaf128..8e2575f6b 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 @@ -77,7 +77,11 @@ export function walkColumns( let x = 0; let clipX = 0; // this tracks the total width of sticky cols const drawY = totalHeaderHeight + translateY; - const clipXRight = freezeTrailingColumns === 0 ? 0 : effectiveCols.slice(-freezeTrailingColumns).reduce((acc, col) => acc + col.width, 0); + + 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]; @@ -121,9 +125,12 @@ export function walkGroups( let x = 0; let clipX = 0; - const effectiveColsRight = freezeTrailingColumns === 0 ? [] : effectiveCols.slice(-freezeTrailingColumns); - const widthRight = effectiveColsRight.reduce((acc, col) => acc + col.width, 0); - width -= widthRight; + // 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]; @@ -150,7 +157,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 ?? "", @@ -163,18 +170,21 @@ export function walkGroups( x += boxWidth; } - for (let index = 0; index < effectiveColsRight.length; index++) { - const startCol = effectiveColsRight[index]; + 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 < effectiveColsRight.length && - isGroupEqual(effectiveColsRight[end].group, startCol.group) && - effectiveColsRight[end].sticky === effectiveColsRight[index].sticky + end < effectiveCols.length && + isGroupEqual(effectiveCols[end].group, startCol.group) && + effectiveCols[end].sticky === effectiveCols[index].sticky ) { - const endCol = effectiveColsRight[end]; + const endCol = effectiveCols[end]; boxWidth += endCol.width; end++; index++; @@ -183,12 +193,12 @@ export function walkGroups( } } - const t = width + boxWidth; + const t = currentWidth + boxWidth; const localX = t - boxWidth; const delta = 0; const w = boxWidth - delta; cb( - [startCol.sourceIndex, effectiveColsRight[end - 1].sourceIndex], + [startCol.sourceIndex, effectiveCols[end - 1].sourceIndex], startCol.group ?? "", localX + delta, 0, @@ -196,7 +206,7 @@ export function walkGroups( groupHeaderHeight ); - width += w; + currentWidth += w; } } From 388eafcf8660c5f1074af8c9d9caf98d0eec8fb4 Mon Sep 17 00:00:00 2001 From: Sassoun Derderian Date: Fri, 22 Aug 2025 14:11:49 +0400 Subject: [PATCH 13/25] fix: ensure freeze columns cannot exceed total column count Fix test to perform click with the pointer events. --- packages/core/src/data-editor/data-editor.tsx | 9 +++++++-- packages/core/test/data-grid.test.tsx | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index c3b03a53b..9189076ca 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -4026,7 +4026,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, @@ -4294,7 +4299,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { render(); - fireEvent.mouseDown(screen.getByTestId(dataGridCanvasId), { + fireEvent.pointerDown(screen.getByTestId(dataGridCanvasId), { clientX: 950, // Col A clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) }); - fireEvent.mouseUp(screen.getByTestId(dataGridCanvasId), { + fireEvent.pointerUp(screen.getByTestId(dataGridCanvasId), { clientX: 950, // Col A clientY: 36 + 32 * 5 + 16, // Row 5 (0 indexed) }); From 67196ea6c2ad813a72e020a2cc01859db1b276f6 Mon Sep 17 00:00:00 2001 From: Mihran Margaryan Date: Thu, 18 Dec 2025 12:46:03 +0400 Subject: [PATCH 14/25] update API.md with new freezeColumns api --- packages/core/API.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) 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 From a5c32acf83194a19fb1618ea381119375fd3e481 Mon Sep 17 00:00:00 2001 From: annaha Date: Mon, 26 Jan 2026 12:51:34 +0400 Subject: [PATCH 15/25] fix: add keyboard support for context menu --- .../core/src/internal/data-grid/data-grid.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index 338a52e10..c35f20c4b 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -1263,6 +1263,25 @@ const DataGrid: React.ForwardRefRenderFunction = (p, ); useEventListener("contextmenu", onContextMenuImpl, eventTargetRef?.current ?? null, false); + const onContextMenuWithKeyboardImpl = React.useCallback( + (ev: MouseEvent) => { + const canvas = ref.current; + const currentSelectedCell = selectionRef.current.current?.cell + if (canvas === null || onContextMenu === undefined || currentSelectedCell === undefined) + return; + const bounds = getBoundsForItem(canvas, ...currentSelectedCell); + if (bounds) { + const args = getMouseArgsForPosition(canvas, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ev); + onContextMenu(args, () => { + if (ev.cancelable) ev.preventDefault(); + }); + } + }, + [getMouseArgsForPosition, onContextMenu, getBoundsForItem] + ); + + useEventListener("contextmenu", onContextMenuWithKeyboardImpl, ref?.current ?? null, false); + const onAnimationFrame = React.useCallback(values => { damageRegion.current = new CellSet(values.map(x => x.item)); hoverValues.current = values; From 80d78d38c9e42765aea86b2719bf4cf23eaa5d1d Mon Sep 17 00:00:00 2001 From: annaha Date: Tue, 27 Jan 2026 23:08:02 +0400 Subject: [PATCH 16/25] add unit tests --- packages/core/test/data-editor.test.tsx | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 4a0d97369..f91338e40 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -196,6 +196,53 @@ describe("data-editor", () => { expect(spySelection).not.toHaveBeenCalled(); }); + test("opens contextmenu at selected cell position when fired on canvas", async () => { + const spy = vi.fn(); + + vi.useFakeTimers(); + render(, { wrapper: Context }); + + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.contextMenu(canvas); + + expect(spy).toHaveBeenCalledWith([1, 1], expect.anything()); + }); + + test("does not open contextmenu when no cell selected", async () => { + const spy = vi.fn(); + + vi.useFakeTimers(); + render(, { wrapper: Context }); + + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.contextMenu(canvas); + + expect(spy).not.toHaveBeenCalled(); + }); + test("middle click does not change selection", async () => { const spySelection = vi.fn(); From 4031bf346420509bee506645303c34b122b7dc49 Mon Sep 17 00:00:00 2001 From: annaha Date: Mon, 2 Feb 2026 19:20:22 +0400 Subject: [PATCH 17/25] fix: add keyBinding for context menu --- .../data-editor/data-editor-keybindings.ts | 4 + packages/core/src/data-editor/data-editor.tsx | 56 ++++++--- .../core/src/internal/data-grid/data-grid.tsx | 19 --- packages/core/test/data-editor.test.tsx | 118 +++++++++++------- 4 files changed, 114 insertions(+), 83 deletions(-) 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..fa9b926dc 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3263,6 +3263,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction = (p, ); useEventListener("contextmenu", onContextMenuImpl, eventTargetRef?.current ?? null, false); - const onContextMenuWithKeyboardImpl = React.useCallback( - (ev: MouseEvent) => { - const canvas = ref.current; - const currentSelectedCell = selectionRef.current.current?.cell - if (canvas === null || onContextMenu === undefined || currentSelectedCell === undefined) - return; - const bounds = getBoundsForItem(canvas, ...currentSelectedCell); - if (bounds) { - const args = getMouseArgsForPosition(canvas, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ev); - onContextMenu(args, () => { - if (ev.cancelable) ev.preventDefault(); - }); - } - }, - [getMouseArgsForPosition, onContextMenu, getBoundsForItem] - ); - - useEventListener("contextmenu", onContextMenuWithKeyboardImpl, ref?.current ?? null, false); - const onAnimationFrame = React.useCallback(values => { damageRegion.current = new CellSet(values.map(x => x.item)); hoverValues.current = values; diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index f91338e40..8aa32954f 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -196,53 +196,6 @@ describe("data-editor", () => { expect(spySelection).not.toHaveBeenCalled(); }); - test("opens contextmenu at selected cell position when fired on canvas", async () => { - const spy = vi.fn(); - - vi.useFakeTimers(); - render(, { wrapper: Context }); - - prep(); - - const canvas = screen.getByTestId("data-grid-canvas"); - - fireEvent.contextMenu(canvas); - - expect(spy).toHaveBeenCalledWith([1, 1], expect.anything()); - }); - - test("does not open contextmenu when no cell selected", async () => { - const spy = vi.fn(); - - vi.useFakeTimers(); - render(, { wrapper: Context }); - - prep(); - const canvas = screen.getByTestId("data-grid-canvas"); - fireEvent.contextMenu(canvas); - - expect(spy).not.toHaveBeenCalled(); - }); - test("middle click does not change selection", async () => { const spySelection = vi.fn(); @@ -1196,6 +1149,77 @@ 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), + }), + }); + + expect(eventArgs.localEventX).toBe(eventArgs.bounds.width / 2); + expect(eventArgs.localEventY).toBe(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"); + + fireEvent.keyDown(canvas, { + key: "F10", + keyCode: 121, + shiftKey: true, + }); + + expect(spy).not.toHaveBeenCalled(); + }); + test("Delete cell", async () => { const spy = vi.fn(); From 20afebe49f7f162705be7e26b3867e2e37def459 Mon Sep 17 00:00:00 2001 From: annaha Date: Tue, 3 Feb 2026 10:31:15 +0400 Subject: [PATCH 18/25] resolve comments --- packages/core/src/data-editor/data-editor.tsx | 50 +++++++++++-------- packages/core/test/data-editor.test.tsx | 5 +- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index fa9b926dc..989274ef1 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3263,7 +3263,6 @@ const DataEditorImpl: React.ForwardRefRenderFunction { width: expect.any(Number), height: expect.any(Number), }), + localEventX: eventArgs.bounds.width / 2, + localEventY: eventArgs.bounds.height / 2, }); - expect(eventArgs.localEventX).toBe(eventArgs.bounds.width / 2); - expect(eventArgs.localEventY).toBe(eventArgs.bounds.height / 2); }); test("does not open context menu with Shift+F10 when no cells are selected", async () => { @@ -1211,6 +1211,7 @@ describe("data-editor", () => { const canvas = screen.getByTestId("data-grid-canvas"); + // Press Shift+F10 without selecting any cell fireEvent.keyDown(canvas, { key: "F10", keyCode: 121, From aa8e4571a7ca46467ae5fe25b5fac8b2ac3de71a Mon Sep 17 00:00:00 2001 From: annaha Date: Tue, 3 Feb 2026 15:02:21 +0400 Subject: [PATCH 19/25] fix spacing --- packages/core/src/data-editor/data-editor.tsx | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 989274ef1..29c80237c 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3362,52 +3362,53 @@ const DataEditorImpl: React.ForwardRefRenderFunction Date: Tue, 3 Feb 2026 15:45:59 +0400 Subject: [PATCH 20/25] fix lint --- packages/core/src/data-editor/data-editor.tsx | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 29c80237c..755057331 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3185,6 +3185,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 = () => { @@ -3480,6 +3516,7 @@ 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; From 1b69a2553a9eb7168297900dc5836e2bb9a610c9 Mon Sep 17 00:00:00 2001 From: annaha Date: Fri, 3 Jul 2026 14:56:34 +0400 Subject: [PATCH 21/25] fix: repaint per-column fill for spanning cells in frozen trailing rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walkRowsInCol` does not clip scrolling rows at freezeY, so the last visible scrolling row's y-range crosses into the freeze band. Glide's normal flow relies on each column repainting the frozen row's fill after the scrolling walk to overwrite that overflow. With a wide `span` this short-circuits via `handledSpans` for every column after the first, leaving scrolling-row content visible inside the frozen span; per-column selection/highlight accents were also skipped for the same reason. Fall through with `skipContents=true` for sticky rows so each column repaints its own fill. Content stays drawn once by the initial span push. Non-frozen spans keep the short-circuit — they don't hit the overflow-into-freeze-band issue and rely on the wide-fill column for highlightRegions. Adds a regression test that fails without the fix. --- .../render/data-grid-render.cells.ts | 11 ++++- packages/core/test/data-editor.test.tsx | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) 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..3e6eea137 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 @@ -256,8 +256,15 @@ export function drawCells( drawingSpan = true; } } else { - toDraw--; - return; + // Frozen trailing rows: fall through with skipContents=true so each column + // repaints its own fill (overwriting scrolling-row overflow past freezeY + // and applying per-column selection/highlight). Content stays drawn once + // by the span push above. Non-frozen spans keep the short-circuit. + if (!isSticky) { + toDraw--; + return; + } + skipContents = true; } } diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 4a0d97369..7519ec689 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -3830,6 +3830,49 @@ describe("data-editor", () => { }); }); + test("Span in frozen trailing row repaints fill per column (no bleed)", async () => { + // Regression: with a spanning cell in a frozen trailing row, every column must + // paint its own fill so scrolling-row overflow at freezeY is overwritten per + // column — not just once by the first column that hit the span. + 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; + // ±2px tolerates the damage-path +1 inset. h === rowHeight distinguishes row + // fills from cell decoration draws. + const frozenRowFills = fillRectMock.mock.calls.filter( + ([, y, , h]) => typeof y === "number" && Math.abs(y - frozenY) <= 2 && h === rowHeight + ); + + // Without the fix: 1 wide fill (first column only). With it: 1 wide + N-1 per-column. + expect(frozenRowFills.length).toBeGreaterThan(1); + }); + test("Imperative scrollTo false fire", async () => { vi.useFakeTimers(); const ref = React.createRef(); From 48ec0d1d86e7961b9eb4363fe5b5e59b68569b09 Mon Sep 17 00:00:00 2001 From: annaha Date: Fri, 3 Jul 2026 14:56:34 +0400 Subject: [PATCH 22/25] fix: repaint per-column fill for spanning cells in frozen trailing rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walkRowsInCol` does not clip scrolling rows at freezeY, so the last visible scrolling row's y-range crosses into the freeze band. Glide's normal flow relies on each column repainting the frozen row's fill after the scrolling walk to overwrite that overflow. With a wide `span` this short-circuits via `handledSpans` for every column after the first, leaving scrolling-row content visible inside the frozen span; per-column selection/highlight accents were also skipped for the same reason. Fall through with `skipContents=true` for sticky rows so each column repaints its own fill. Content stays drawn once by the initial span push. Non-frozen spans keep the short-circuit — they don't hit the overflow-into-freeze-band issue and rely on the wide-fill column for highlightRegions. Adds a regression test that fails without the fix. --- .../render/data-grid-render.cells.ts | 11 ++++- packages/core/test/data-editor.test.tsx | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) 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 36cb36300..e1be0b586 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 @@ -260,8 +260,15 @@ export function drawCells( drawingSpan = true; } } else { - toDraw--; - return; + // Frozen trailing rows: fall through with skipContents=true so each column + // repaints its own fill (overwriting scrolling-row overflow past freezeY + // and applying per-column selection/highlight). Content stays drawn once + // by the span push above. Non-frozen spans keep the short-circuit. + if (!isSticky) { + toDraw--; + return; + } + skipContents = true; } } diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 8864efe3c..2d935ca3b 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -3949,6 +3949,49 @@ describe("data-editor", () => { }); }); + test("Span in frozen trailing row repaints fill per column (no bleed)", async () => { + // Regression: with a spanning cell in a frozen trailing row, every column must + // paint its own fill so scrolling-row overflow at freezeY is overwritten per + // column — not just once by the first column that hit the span. + 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; + // ±2px tolerates the damage-path +1 inset. h === rowHeight distinguishes row + // fills from cell decoration draws. + const frozenRowFills = fillRectMock.mock.calls.filter( + ([, y, , h]) => typeof y === "number" && Math.abs(y - frozenY) <= 2 && h === rowHeight + ); + + // Without the fix: 1 wide fill (first column only). With it: 1 wide + N-1 per-column. + expect(frozenRowFills.length).toBeGreaterThan(1); + }); + test("Imperative scrollTo false fire", async () => { vi.useFakeTimers(); const ref = React.createRef(); From 7896e723803c556a134ad25329d975314f7436dd Mon Sep 17 00:00:00 2001 From: Sassoun Derderian Date: Mon, 13 Jul 2026 14:41:50 +0400 Subject: [PATCH 23/25] fix(data-grid): prevent scroll rows from bleeding frozen band Stop per-column repainting for spans in frozen trailing rows and restore single-pass span rendering. Clamp scrollable row walking and full redraw painting to the frozen-row boundary, with a sticky-span exception to avoid group-row seam darkening. Update tests for the new row count and span fill behavior. --- .../render/data-grid-render.cells.ts | 44 +++++++++++++++---- .../data-grid/render/data-grid-render.walk.ts | 11 ++++- packages/core/test/data-editor.test.tsx | 27 +++++++----- 3 files changed, 61 insertions(+), 21 deletions(-) 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 3e6eea137..3176382d0 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 @@ -256,15 +256,8 @@ export function drawCells( drawingSpan = true; } } else { - // Frozen trailing rows: fall through with skipContents=true so each column - // repaints its own fill (overwriting scrolling-row overflow past freezeY - // and applying per-column selection/highlight). Content stays drawn once - // by the span push above. Non-frozen spans keep the short-circuit. - if (!isSticky) { - toDraw--; - return; - } - skipContents = true; + toDraw--; + return; } } @@ -364,6 +357,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) { @@ -437,6 +459,10 @@ export function drawCells( ctx.restore(); } + if (didFreezeClip) { + ctx.restore(); + } + if (cell.style === "faded") { ctx.globalAlpha = 1; } 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..d4832e78d 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; diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 7519ec689..d2e779220 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -2689,7 +2689,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,10 +3833,12 @@ describe("data-editor", () => { }); }); - test("Span in frozen trailing row repaints fill per column (no bleed)", async () => { - // Regression: with a spanning cell in a frozen trailing row, every column must - // paint its own fill so scrolling-row overflow at freezeY is overwritten per - // column — not just once by the first column that hit the span. + 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; @@ -3863,14 +3868,16 @@ describe("data-editor", () => { const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; const frozenY = canvas.height - rowHeight; const fillRectMock = ctx.fillRect as unknown as Mock; - // ±2px tolerates the damage-path +1 inset. h === rowHeight distinguishes row - // fills from cell decoration draws. - const frozenRowFills = fillRectMock.mock.calls.filter( + // 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 ); - // Without the fix: 1 wide fill (first column only). With it: 1 wide + N-1 per-column. - expect(frozenRowFills.length).toBeGreaterThan(1); + // 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 () => { From 4114048d1d1ab4c0c5368307e1164e78b674160d Mon Sep 17 00:00:00 2001 From: annaha Date: Mon, 10 Aug 2026 11:55:27 +0400 Subject: [PATCH 24/25] fix: don't block editOnType for cells inside frozen regions The `onKeyDown` visible-region check treats frozen columns/rows as always out of view, since the tracked scrollable region excludes them by design. This silently blocks type-to-edit (pressing a character key to start editing) for any cell inside a frozen region, while Enter-to-edit and click-to-edit are unaffected since neither consults the visible region. Exempt cells inside `vr.extras.freezeRegions` from this check, mirroring the identical `isInFreezeArea` pattern already used in `getMangledCellContent`'s strict-mode branch. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/data-editor/data-editor.tsx | 15 +++++++++- packages/core/test/data-editor.test.tsx | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 40c6b5dd8..a390b3c78 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3619,7 +3619,20 @@ 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", diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 0d358e386..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; From e2d1961d027774bb0cdaa841371f26e7b958ea85 Mon Sep 17 00:00:00 2001 From: annaha Date: Fri, 14 Aug 2026 13:12:00 +0400 Subject: [PATCH 25/25] fix: reset isActivelyDraggingHeader and scrollDir on native dragend Header column reordering uses native HTML5 drag-and-drop. isActivelyDraggingHeader is set true on mousedown for header cells, but was only ever cleared in onMouseUp. Native DnD release delivers dragend, not mouseup, to the drag source, so the ref stayed stuck true after any header drag. Every subsequent mousemove then re-armed scrollDir from the live cursor position (see onMouseMoveImpl), driving the autoscroll RAF loop indefinitely with no mouse button held, until the user produced a real mouseup via an unrelated click. Reset both isActivelyDraggingHeader and scrollDir in onDragEnd, which already fires unconditionally on the native dragend event. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/data-editor/data-editor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index a390b3c78..9e9ea046b 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -2736,6 +2736,8 @@ const DataEditorImpl: React.ForwardRefRenderFunction { isActivelyDragging.current = false; + isActivelyDraggingHeader.current = false; + setScrollDir(undefined); }, []); const rowGroupingSelectionBehavior = rowGrouping?.selectionBehavior;