diff --git a/examples/src/pages/demos/new-perf-approach.page.tsx b/examples/src/pages/demos/new-perf-approach.page.tsx index 7600e2c6..bdec35dc 100644 --- a/examples/src/pages/demos/new-perf-approach.page.tsx +++ b/examples/src/pages/demos/new-perf-approach.page.tsx @@ -65,8 +65,8 @@ export default function App() { brain.update({ rowHeight: 40, colWidth: 150, - width, - height, + width: width, + height: height, rows: 1500, cols: 4, }); diff --git a/examples/src/pages/tests/horizontal-brain.spec.ts b/examples/src/pages/tests/horizontal-brain.spec.ts new file mode 100644 index 00000000..b0813c01 --- /dev/null +++ b/examples/src/pages/tests/horizontal-brain.spec.ts @@ -0,0 +1,358 @@ +import { test, expect } from '@playwright/test'; +import { OnScrollFn } from '@src/components/types/ScrollPosition'; +import { HorizontalLayoutMatrixBrain } from '@src/components/VirtualBrain/HorizontalLayoutMatrixBrain'; +import { FnOnRenderRangeChange } from '@src/components/VirtualBrain/MatrixBrain'; + +const sinon = require('sinon'); + +type ExtraProps = { callCount: number; firstArg: any }; + +export default test.describe.parallel('HorizontalLayoutMatrixBrain', () => { + test.beforeEach(({ page }) => { + globalThis.__DEV__ = true; + page.on('console', async (msg) => { + const values = []; + for (const arg of msg.args()) values.push(await arg.jsonValue()); + console.log(...values); + }); + }); + + test('getMatrixCoordinatesForHorizontalLayoutPosition', async () => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 220; + const HEIGHT = 160; + const ROWS = 50; + const COLS = 2; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + expect(brain.rowsPerPage).toBe(3); + + // rows per page = 3 + expect( + brain.getMatrixCoordinatesForHorizontalLayoutPosition({ + rowIndex: 0, + colIndex: 0, + }), + ).toEqual({ + rowIndex: 0, + colIndex: 0, + }); + + expect( + brain.getMatrixCoordinatesForHorizontalLayoutPosition({ + rowIndex: 5, + colIndex: 0, + }), + ).toEqual({ + rowIndex: 2, + colIndex: 2, + }); + + expect( + brain.getMatrixCoordinatesForHorizontalLayoutPosition({ + rowIndex: 10, + colIndex: 1, + }), + ).toEqual({ + rowIndex: 1, + colIndex: 7, + }); + }); + + test('getHorizontalLayoutPositionFromMatrixCoordinates', async () => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 220; + const HEIGHT = 160; + const ROWS = 50; + const COLS = 2; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + expect(brain.rowsPerPage).toBe(3); + + // rows per page = 3 + expect( + brain.getHorizontalLayoutPositionFromMatrixCoordinates({ + rowIndex: 1, + colIndex: 3, + }), + ).toEqual({ + rowIndex: 4, + colIndex: 1, + }); + + expect( + brain.getHorizontalLayoutPositionFromMatrixCoordinates({ + rowIndex: 0, + colIndex: 6, + }), + ).toEqual({ + rowIndex: 9, + colIndex: 0, + }); + }); + test('should correctly give me the render range', async () => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 230; + const HEIGHT = 420; + const ROWS = 50; + const COLS = 2; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + const initialRange = { + start: [0, 0], + end: [8, 4], + }; + expect(brain.getRenderRange()).toEqual(initialRange); + + // scroll just a bit, to not trigger a render range change + brain.setScrollPosition({ + scrollLeft: 20, + scrollTop: 0, + }); + + expect(brain.getRenderRange()).toEqual(initialRange); + + return; + // scroll horizontally more, to trigger a render range change on horizontal only + brain.setScrollPosition({ + scrollLeft: 120, + scrollTop: 0, + }); + + expect(brain.getRenderRange()).toEqual({ + start: [0, 1], + end: [ + Math.ceil(HEIGHT / ROW_SIZE) + 1, + Math.min(Math.ceil(WIDTH / COL_SIZE) + 2, COLS), + ], + }); + + // scroll horizontally even more, to trigger a render range change on horizontal only + brain.setScrollPosition({ + scrollLeft: 520, + scrollTop: 0, + }); + + expect(brain.getRenderRange()).toEqual({ + start: [0, 3], + end: [Math.ceil(HEIGHT / ROW_SIZE) + 1, 7], + }); + }); + + test('should correctly return the render range when scrolling horizontally', async () => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 230; + const HEIGHT = 420; + const ROWS = 20; + const COLS = 7; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + brain.setScrollPosition({ + scrollLeft: 220, + scrollTop: 0, + }); + + expect(brain.getRenderRange()).toEqual({ + start: [0, 2], + end: [8, 6], + }); + }); + + test('should correctly have initial render range', async () => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 710; + const HEIGHT = 392; + const ROWS = 30; + const COLS = 2; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + expect(brain.getRenderRange()).toEqual({ + start: [0, 0], + end: [7, 9], + }); + }); + + test.skip('THIS WAS COPIED FROM MATRIX BRAIN AND NOT ADJUSTED - should correctly trigger onRenderRange change when scrolling and changing available size', async ({ + page, + }) => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 230; + const HEIGHT = 420; + const ROWS = 20; + const COLS = 7; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + const onRenderRangeChange = sinon.fake() as FnOnRenderRangeChange & + ExtraProps; + const onScroll = sinon.fake() as OnScrollFn & ExtraProps; + + brain.onRenderRangeChange(onRenderRangeChange); + brain.onScroll(onScroll); + + brain.update({ + width: WIDTH + 100, + height: HEIGHT + 100, + }); + + await page.waitForTimeout(5); + + expect(onRenderRangeChange.callCount).toBe(1); + expect(onRenderRangeChange.firstArg).toEqual({ + start: [0, 0], + end: [12, 5], + }); + + // scroll down and right a bit, but not too much so the render range stays the same + brain.setScrollPosition({ + scrollTop: 10, + scrollLeft: 30, + }); + + await page.waitForTimeout(5); + + expect(onRenderRangeChange.callCount).toBe(1); + expect(onScroll.callCount).toBe(1); + + brain.setScrollPosition({ + scrollTop: 60, + scrollLeft: 120, + }); + + await page.waitForTimeout(5); + + expect(onRenderRangeChange.callCount).toBe(2); + expect(onRenderRangeChange.firstArg).toEqual({ + start: [1, 1], + end: [13, 6], + }); + + // now set a new size + + brain.update({ + height: HEIGHT + 200, + width: WIDTH + 200, + }); + + await page.waitForTimeout(5); + + // and expect render range to have changed + expect(onRenderRangeChange.callCount).toBe(3); + expect(onRenderRangeChange.firstArg).toEqual({ + start: [1, 1], + end: [15, 7], + }); + }); + + test.skip('THIS WAS COPIED FROM MATRIX BRAIN AND NOT ADJUSTED - should correctly trigger onRenderRangeChange when count gets smaller than the max render range', async ({ + page, + }) => { + const COL_SIZE = 100; + const ROW_SIZE = 50; + const WIDTH = 230; + const HEIGHT = 420; + const ROWS = 20; + const COLS = 7; + + const brain = new HorizontalLayoutMatrixBrain(); + + brain.update({ + colWidth: COL_SIZE, + rowHeight: ROW_SIZE, + width: WIDTH, + height: HEIGHT, + cols: COLS, + rows: ROWS, + }); + + const onRenderRangeChange = sinon.fake() as FnOnRenderRangeChange & + ExtraProps; + const onScroll = sinon.fake() as OnScrollFn & ExtraProps; + + brain.onRenderRangeChange(onRenderRangeChange); + brain.onScroll(onScroll); + + await page.waitForTimeout(5); + + expect(brain.getRenderRange()).toEqual({ + start: [0, 0], + end: [10, 4], + }); + + brain.update({ + rows: 5, + }); + await page.waitForTimeout(5); + + expect(onRenderRangeChange.callCount).toEqual(1); + expect(onRenderRangeChange.firstArg).toEqual({ + start: [0, 0], + end: [5, 4], + }); + }); +}); diff --git a/examples/src/pages/tests/horizontal-layout/default.page.tsx b/examples/src/pages/tests/horizontal-layout/default.page.tsx new file mode 100644 index 00000000..4ce58d5b --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/default.page.tsx @@ -0,0 +1,63 @@ +import { + InfiniteTable, + DataSource, + type InfiniteTablePropColumns, +} from '@infinite-table/infinite-react'; + +import * as React from 'react'; +import { useState } from 'react'; +import { Developer, dataSource } from './horiz-layout-data'; + +const columns: InfiniteTablePropColumns = { + id: { + field: 'id', + type: 'number', + /*xdefaultWidth: 80,*/ renderValue: ({ value }) => value - 1, + }, + preferredLanguage: { field: 'preferredLanguage' /*xdefaultWidth: 110 */ }, + // age: { field: 'age' /*xdefaultWidth: 70 */ }, + // salary: { + // field: 'salary', + // type: 'number', + // /*xdefaultWidth: 100,*/ + // }, +}; + +export function getRandomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +const domProps = { style: { height: '30vh', width: 900 } }; + +// dataSource.length = 12; + +export default function App() { + const [wrapRowsHorizontally, setWrapRowsHorizontally] = useState(true); + return ( + <> + + + primaryKey="id" + data={dataSource} + key={`${wrapRowsHorizontally}`} + > + + wrapRowsHorizontally={wrapRowsHorizontally} + rowHeight={50} + domProps={domProps} + columns={columns} + columnDefaultWidth={150} + onCellClick={({ rowIndex, colIndex }) => { + console.log('clicked', rowIndex, colIndex); + }} + /> + + + ); +} diff --git a/examples/src/pages/tests/horizontal-layout/horiz-layout-data.ts b/examples/src/pages/tests/horizontal-layout/horiz-layout-data.ts new file mode 100644 index 00000000..5aea8593 --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/horiz-layout-data.ts @@ -0,0 +1,242 @@ +export type Developer = { + id: number; + firstName: string; + lastName: string; + country: string; + city: string; + currency: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + hobby: string; + salary: number; + monthlyBonus: number; + age: number; +}; +export const dataSource: Developer[] = [ + { + id: 1, + firstName: 'John', + lastName: 'Doe', + country: 'USA', + city: 'New York', + currency: 'USD', + preferredLanguage: 'JavaScript', + stack: 'MERN', + canDesign: 'yes', + hobby: 'Photography', + salary: 95000, + monthlyBonus: 1000, + age: 28, + }, + { + id: 2, + firstName: 'Jane', + lastName: 'Smith', + country: 'UK', + city: 'London', + currency: 'GBP', + preferredLanguage: 'Python', + stack: 'Django', + canDesign: 'no', + hobby: 'Hiking', + salary: 85000, + monthlyBonus: 800, + age: 32, + }, + { + id: 3, + firstName: 'Alex', + lastName: 'Johnson', + country: 'Canada', + city: 'Toronto', + currency: 'CAD', + preferredLanguage: 'Java', + stack: 'Spring', + canDesign: 'yes', + hobby: 'Chess', + salary: 90000, + monthlyBonus: 950, + age: 35, + }, + { + id: 4, + firstName: 'Maria', + lastName: 'Garcia', + country: 'Spain', + city: 'Barcelona', + currency: 'EUR', + preferredLanguage: 'TypeScript', + stack: 'MEAN', + canDesign: 'yes', + hobby: 'Painting', + salary: 78000, + monthlyBonus: 700, + age: 29, + }, + { + id: 5, + firstName: 'Yuki', + lastName: 'Tanaka', + country: 'Japan', + city: 'Tokyo', + currency: 'JPY', + preferredLanguage: 'Ruby', + stack: 'Ruby on Rails', + canDesign: 'no', + hobby: 'Origami', + salary: 88000, + monthlyBonus: 900, + age: 31, + }, + { + id: 6, + firstName: 'Lars', + lastName: 'Andersen', + country: 'Denmark', + city: 'Copenhagen', + currency: 'DKK', + preferredLanguage: 'C#', + stack: '.NET', + canDesign: 'no', + hobby: 'Cycling', + salary: 92000, + monthlyBonus: 1100, + age: 38, + }, + { + id: 7, + firstName: 'Priya', + lastName: 'Patel', + country: 'India', + city: 'Mumbai', + currency: 'INR', + preferredLanguage: 'Go', + stack: 'Microservices', + canDesign: 'yes', + hobby: 'Yoga', + salary: 75000, + monthlyBonus: 600, + age: 27, + }, + { + id: 8, + firstName: 'Mohamed', + lastName: 'Ali', + country: 'Egypt', + city: 'Cairo', + currency: 'EGP', + preferredLanguage: 'PHP', + stack: 'Laravel', + canDesign: 'no', + hobby: 'Reading', + salary: 70000, + monthlyBonus: 500, + age: 33, + }, + { + id: 9, + firstName: 'Sophie', + lastName: 'Martin', + country: 'France', + city: 'Paris', + currency: 'EUR', + preferredLanguage: 'Swift', + stack: 'iOS', + canDesign: 'yes', + hobby: 'Cooking', + salary: 86000, + monthlyBonus: 850, + age: 30, + }, + { + id: 10, + firstName: 'Lucas', + lastName: 'Silva', + country: 'Brazil', + city: 'São Paulo', + currency: 'BRL', + preferredLanguage: 'Kotlin', + stack: 'Android', + canDesign: 'no', + hobby: 'Surfing', + salary: 72000, + monthlyBonus: 550, + age: 26, + }, + { + id: 11, + firstName: 'Emma', + lastName: 'Wilson', + country: 'Australia', + city: 'Sydney', + currency: 'AUD', + preferredLanguage: 'Rust', + stack: 'WebAssembly', + canDesign: 'yes', + hobby: 'Gardening', + salary: 94000, + monthlyBonus: 1050, + age: 36, + }, + { + id: 12, + firstName: 'Rajesh', + lastName: 'Kumar', + country: 'Singapore', + city: 'Singapore', + currency: 'SGD', + preferredLanguage: 'Scala', + stack: 'Akka', + canDesign: 'no', + hobby: 'Meditation', + salary: 98000, + monthlyBonus: 1200, + age: 40, + }, + { + id: 13, + firstName: 'Anna', + lastName: 'Kowalski', + country: 'Poland', + city: 'Warsaw', + currency: 'PLN', + preferredLanguage: 'Elixir', + stack: 'Phoenix', + canDesign: 'yes', + hobby: 'Dancing', + salary: 76000, + monthlyBonus: 650, + age: 29, + }, + { + id: 14, + firstName: 'Chen', + lastName: 'Wei', + country: 'China', + city: 'Shanghai', + currency: 'CNY', + preferredLanguage: 'Dart', + stack: 'Flutter', + canDesign: 'yes', + hobby: 'Calligraphy', + salary: 80000, + monthlyBonus: 750, + age: 28, + }, + { + id: 15, + firstName: 'Liam', + lastName: "O'Connor", + country: 'Ireland', + city: 'Dublin', + currency: 'EUR', + preferredLanguage: 'Haskell', + stack: 'Functional', + canDesign: 'no', + hobby: 'Music', + salary: 88000, + monthlyBonus: 900, + age: 34, + }, +]; diff --git a/examples/src/pages/tests/horizontal-layout/renderer.page.tsx b/examples/src/pages/tests/horizontal-layout/renderer.page.tsx new file mode 100644 index 00000000..21902107 --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/renderer.page.tsx @@ -0,0 +1,77 @@ +import { HorizontalLayoutMatrixBrain } from '@src/components/VirtualBrain/HorizontalLayoutMatrixBrain'; +import { useState } from 'react'; +import * as React from 'react'; +import { + VirtualScrollContainer, + VirtualScrollContainerChildToScrollCls, +} from '@src/components/VirtualScrollContainer'; +import { ScrollPosition } from '@src/components/types/ScrollPosition'; +import { useResizeObserver } from '@src/components/ResizeObserver'; + +export default function App() { + const [brain] = useState(() => { + return new HorizontalLayoutMatrixBrain('horizontal-layout'); + }); + + const scrollContentRef = React.useRef(null); + const scrollerDOMRef = React.useRef(null); + + const onContainerScroll = React.useCallback((scrollPos: ScrollPosition) => { + brain.setScrollPosition(scrollPos, () => { + scrollContentRef.current!.style.transform = `translate3d(-${ + scrollPos.scrollLeft + }px, ${-scrollPos.scrollTop}px, 0px)`; + }); + }, []); + + useResizeObserver( + scrollerDOMRef, + (size) => { + const bodySize = { + width: size.width, + height: size.height, + }; + + brain.update(bodySize); + }, + { earlyAttach: true, debounce: 50 }, + ); + + return ( + +
+ +
+
+
+ ); +} diff --git a/examples/src/pages/tests/horizontal-layout/test.page.tsx b/examples/src/pages/tests/horizontal-layout/test.page.tsx new file mode 100644 index 00000000..acdde335 --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/test.page.tsx @@ -0,0 +1,69 @@ +import { + InfiniteTable, + DataSource, + type InfiniteTablePropColumns, +} from '@infinite-table/infinite-react'; + +import * as React from 'react'; +import { useState } from 'react'; + +type Developer = { + id: number; + preferredLanguage: string; + age: number; + salary: number; +}; +const columns: InfiniteTablePropColumns = { + id: { + field: 'id', + type: 'number', + }, + preferredLanguage: { field: 'preferredLanguage' }, + // age: { field: 'age' }, + // salary: { field: 'salary' }, +}; + +const domProps = { + // style: { height: 420 /*30px header, 420 body*/, width: 230 }, + style: { height: '50vh' /*30px header, 420 body*/, width: '50vw' }, +}; + +const data = Array.from({ length: 10 }, (_, i) => ({ + id: i, + preferredLanguage: `Lang ${i}`, + age: i * 10, + salary: i * 1000, +})); + +export default function App() { + const [wrapRowsHorizontally, setWrapRowsHorizontally] = useState(true); + return ( + <> + + + primaryKey="id" + data={data} + key={`${wrapRowsHorizontally}`} + > + + wrapRowsHorizontally={wrapRowsHorizontally} + rowHeight={50} + domProps={domProps} + header={false} + columnHeaderHeight={30} + columns={columns} + columnDefaultWidth={100} + onCellClick={({ rowIndex, colIndex }) => { + console.log('clicked', rowIndex, colIndex); + }} + /> + + + ); +} diff --git a/examples/src/pages/tests/matrix-brain.spec.ts b/examples/src/pages/tests/matrix-brain.spec.ts index 4df51394..a62fd17d 100644 --- a/examples/src/pages/tests/matrix-brain.spec.ts +++ b/examples/src/pages/tests/matrix-brain.spec.ts @@ -136,7 +136,7 @@ export default test.describe.parallel('MatrixBrain', () => { brain.onRenderRangeChange(onRenderRangeChange); brain.onScroll(onScroll); - brain.setAvailableSize({ + brain.update({ width: WIDTH + 100, height: HEIGHT + 100, }); diff --git a/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx b/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx index 7e6c8908..bec46c42 100644 --- a/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx +++ b/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx @@ -85,10 +85,10 @@ export function HeadlessTableWithPinnedContainersFn( useEffect(() => { const removeOnRenderCount = brain.onRenderCountChange(() => { - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); }); - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); return () => { removeOnRenderCount(); diff --git a/source/src/components/HeadlessTable/HorizontalLayoutTableRenderer.tsx b/source/src/components/HeadlessTable/HorizontalLayoutTableRenderer.tsx index ed851e07..4e6dd556 100644 --- a/source/src/components/HeadlessTable/HorizontalLayoutTableRenderer.tsx +++ b/source/src/components/HeadlessTable/HorizontalLayoutTableRenderer.tsx @@ -1,3 +1,5 @@ +import { TableRenderRange } from '../InfiniteTable'; +import { Renderable } from '../types/Renderable'; import { HorizontalLayoutMatrixBrain } from '../VirtualBrain/HorizontalLayoutMatrixBrain'; import { columnOffsetAtIndex, @@ -5,6 +7,7 @@ import { currentTransformY, ReactHeadlessTableRenderer, TableRenderCellFn, + TableRenderDetailRowFn, } from './ReactHeadlessTableRenderer'; export class HorizontalLayoutTableRenderer extends ReactHeadlessTableRenderer { @@ -14,99 +17,34 @@ export class HorizontalLayoutTableRenderer extends ReactHeadlessTableRenderer { this.brain = brain; } - protected old_renderCellAtElement( - rowIndex: number, - colIndex: number, - elementIndex: number, - renderCell: TableRenderCellFn, - ) { - if (this.destroyed) { - return; - } - - const covered = this.isCellCovered(rowIndex, colIndex); - - const height = this.brain.getRowHeight(rowIndex); - const width = this.brain.getColWidth(colIndex); - - const rowspan = this.brain.getRowspan(rowIndex, colIndex); - const colspan = this.brain.getColspan(rowIndex, colIndex); - - const heightWithRowspan = - rowspan === 1 - ? height - : this.brain.getRowHeightWithSpan(rowIndex, colIndex, rowspan); - - const widthWithColspan = - colspan === 1 - ? width - : this.brain.getColWidthWithSpan(rowIndex, colIndex, colspan); - - const { row: rowFixed, col: colFixed } = this.isCellFixed( + protected getCellRealCoordinates(rowIndex: number, colIndex: number) { + return this.brain.getHorizontalLayoutPositionFromMatrixCoordinates({ rowIndex, colIndex, - ); - - const hidden = !!covered; - - const renderedNode = renderCell({ - rowIndex, - colIndex: colIndex % this.brain.initialCols, - height, - width, - rowspan, - colspan, - rowFixed, - colFixed, - hidden, - heightWithRowspan, - widthWithColspan, - onMouseEnter: this.onMouseEnter.bind(null, rowIndex), - onMouseLeave: this.onMouseLeave.bind(null, rowIndex), - domRef: this.itemDOMRefs[elementIndex], }); - - const itemUpdater = this.updaters[elementIndex]; - - if (!itemUpdater) { - this.error( - `Cannot find item updater for item ${rowIndex},${colIndex} at this time... sorry.`, - ); - return; - } - - // console.log('render row', rowIndex); - - this.mappedCells.renderCellAtElement( - rowIndex, - colIndex, - elementIndex, - renderedNode, - ); - - if (__DEV__) { - this.debug( - `Render cell ${rowIndex},${colIndex} at element ${elementIndex}`, - ); - } - - // console.log('update', rowIndex, colIndex, renderedNode); - itemUpdater(renderedNode); - - this.updateElementPosition(elementIndex, { hidden, rowspan, colspan }); - return; } - protected getCellRealCoordinates(rowIndex: number, colIndex: number) { - const coords = this.brain.getHorizontalLayoutPositionFromMatrixCoordinates({ - rowIndex, - colIndex, + renderRange( + range: TableRenderRange, + + { + renderCell, + renderDetailRow, + force, + onRender, + }: { + force?: boolean; + renderCell: TableRenderCellFn; + renderDetailRow?: TableRenderDetailRowFn; + onRender: (items: Renderable[]) => void; + }, + ): Renderable[] { + return super.renderRange(range, { + renderCell, + renderDetailRow, + force, + onRender, }); - // console.log([rowIndex, colIndex], ' -> ', [ - // coords.rowIndex, - // coords.colIndex, - // ]); - return coords; } setTransform = ( @@ -151,54 +89,6 @@ export class HorizontalLayoutTableRenderer extends ReactHeadlessTableRenderer { const transformValue = `translate3d(${transformX}, ${transformY}, 0px)`; - //@ts-ignore - if (element.__transformValue !== transformValue) { - //@ts-ignore - element.__transformValue = transformValue; - element.style.transform = transformValue; - } - }; - setTransform_old = ( - element: HTMLElement, - rowIndex: number, - colIndex: number, - - options: { - x: number; - y: number; - scrollLeft?: boolean; - scrollTop?: boolean; - }, - zIndex: number | 'auto' | undefined | null, - ) => { - const horizontalLayoutCoords = this.getCellRealCoordinates( - rowIndex, - colIndex, - ); - console.log(horizontalLayoutCoords); - colIndex = colIndex % this.brain.initialCols; - const { y } = options; - const pageIndex = Math.floor(rowIndex / this.brain.rowsPerPage); - const pageOffset = pageIndex ? pageIndex * this.brain.pageWidth : 0; - - const columnOffsetX = `${columnOffsetAtIndex}-${colIndex}`; - const columnOffsetXWhileReordering = `${columnOffsetAtIndexWhileReordering}-${colIndex}`; - - const currentTransformYValue = `${y}px`; - - //@ts-ignore - if (element.__currentTransformY !== currentTransformYValue) { - //@ts-ignore - element.__currentTransformY = currentTransformYValue; - element.style.setProperty(currentTransformY, currentTransformYValue); - } - - const xOffset = `calc(var(${columnOffsetX}) + ${pageOffset}px)`; - const transformX = `var(${columnOffsetXWhileReordering}, ${xOffset})`; - const transformY = `var(${currentTransformY})`; - - const transformValue = `translate3d(${transformX}, ${transformY}, 0px)`; - //@ts-ignore if (element.__transformValue !== transformValue) { //@ts-ignore diff --git a/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx b/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx index 3e1b0ed2..0e0224ae 100644 --- a/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx +++ b/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx @@ -63,35 +63,35 @@ export type RenderableWithPosition = { position: 'start' | 'end' | null; }; -const ITEM_POSITION_WITH_TRANSFORM = true; +export const ITEM_POSITION_WITH_TRANSFORM = true; -const currentTransformY = stripVar(InternalVars.y); +export const currentTransformY = stripVar(InternalVars.y); -const scrollTopCSSVar = stripVar(InternalVars.scrollTop); -const columnOffsetAtIndex = stripVar(InternalVars.columnOffsetAtIndex); -const columnOffsetAtIndexWhileReordering = stripVar( +export const scrollTopCSSVar = stripVar(InternalVars.scrollTop); +export const columnOffsetAtIndex = stripVar(InternalVars.columnOffsetAtIndex); +export const columnOffsetAtIndexWhileReordering = stripVar( InternalVars.columnOffsetAtIndexWhileReordering, ); export class ReactHeadlessTableRenderer extends Logger { - private brain: MatrixBrain; + protected brain: MatrixBrain; public debugId: string = ''; - private destroyed = false; + protected destroyed = false; private scrolling = false; public cellHoverClassNames: string[] = []; private itemDOMElements: (HTMLElement | null)[] = []; - private itemDOMRefs: RefCallback[] = []; - private updaters: SubscriptionCallback[] = []; + protected itemDOMRefs: RefCallback[] = []; + protected updaters: SubscriptionCallback[] = []; private detailRowDOMElements: (HTMLElement | null)[] = []; private detailRowDOMRefs: RefCallback[] = []; private detailRowUpdaters: SubscriptionCallback[] = []; - private mappedCells: MappedCells; + protected mappedCells: MappedCells; private mappedDetailRows: MappedVirtualRows; private items: Renderable[] = []; @@ -201,6 +201,8 @@ export class ReactHeadlessTableRenderer extends Logger { this.mappedCells = new MappedCells(); this.mappedDetailRows = new MappedVirtualRows(); + this.renderRange = this.renderRange.bind(this); + const removeOnScroll = brain.onScroll(this.adjustFixedElementsOnScroll); const removeOnSizeChange = brain.onAvailableSizeChange(() => { this.adjustFixedElementsOnScroll(); @@ -570,7 +572,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - renderRange = ( + renderRange( range: TableRenderRange, { @@ -584,7 +586,7 @@ export class ReactHeadlessTableRenderer extends Logger { renderDetailRow?: TableRenderDetailRowFn; onRender: (items: Renderable[]) => void; }, - ): Renderable[] => { + ): Renderable[] { if (this.destroyed) { return []; } @@ -903,7 +905,7 @@ export class ReactHeadlessTableRenderer extends Logger { // } return result; - }; + } private renderElement(elementIndex: number) { const domRef = (node: HTMLElement | null) => { @@ -1106,7 +1108,7 @@ export class ReactHeadlessTableRenderer extends Logger { return arr; }; - private isCellFixed = ( + protected isCellFixed = ( rowIndex: number, colIndex: number, ): { row: FixedPosition; col: FixedPosition } => { @@ -1154,7 +1156,7 @@ export class ReactHeadlessTableRenderer extends Logger { }; }; - private isCellCovered = (rowIndex: number, colIndex: number) => { + protected isCellCovered = (rowIndex: number, colIndex: number) => { const rowspanParent = this.brain.getRowspanParent(rowIndex, colIndex); const colspanParent = this.brain.getColspanParent(rowIndex, colIndex); @@ -1225,7 +1227,15 @@ export class ReactHeadlessTableRenderer extends Logger { this.updateDetailElementPosition(detailElementIndex); return; } - private renderCellAtElement( + + protected getCellRealCoordinates(rowIndex: number, colIndex: number) { + return { + rowIndex, + colIndex, + }; + } + + protected renderCellAtElement( rowIndex: number, colIndex: number, elementIndex: number, @@ -1260,9 +1270,12 @@ export class ReactHeadlessTableRenderer extends Logger { const hidden = !!covered; + const { rowIndex: renderRowIndex, colIndex: renderColIndex } = + this.getCellRealCoordinates(rowIndex, colIndex); + const renderedNode = renderCell({ - rowIndex, - colIndex, + rowIndex: renderRowIndex, + colIndex: renderColIndex, height, width, rowspan, @@ -1286,8 +1299,6 @@ export class ReactHeadlessTableRenderer extends Logger { return; } - // console.log('render row', rowIndex); - this.mappedCells.renderCellAtElement( rowIndex, colIndex, @@ -1308,7 +1319,7 @@ export class ReactHeadlessTableRenderer extends Logger { return; } - private onMouseEnter = (rowIndex: number) => { + protected onMouseEnter = (rowIndex: number) => { this.currentHoveredRow = rowIndex; if (this.scrolling) { @@ -1329,7 +1340,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - private onMouseLeave = (rowIndex: number) => { + protected onMouseLeave = (rowIndex: number) => { if (this.currentHoveredRow != -1 && this.currentHoveredRow === rowIndex) { this.removeHoverClass(rowIndex); } @@ -1352,7 +1363,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - private updateHoverClassNamesForRow = (rowIndex: number) => { + protected updateHoverClassNamesForRow = (rowIndex: number) => { if (this.scrolling) { return; } @@ -1384,7 +1395,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - private updateElementPosition = ( + protected updateElementPosition = ( elementIndex: number, options?: { hidden: boolean; rowspan: number; colspan: number }, ) => { @@ -1417,9 +1428,10 @@ export class ReactHeadlessTableRenderer extends Logger { // itemElement.style.gridRow = `${rowIndex} / span 1`; // (itemElement.dataset as any).elementIndex = elementIndex; - (itemElement.dataset as any).rowIndex = rowIndex; + const realCoords = this.getCellRealCoordinates(rowIndex, colIndex); + (itemElement.dataset as any).rowIndex = realCoords.rowIndex; - (itemElement.dataset as any).colIndex = colIndex; + (itemElement.dataset as any).colIndex = realCoords.colIndex; if (ITEM_POSITION_WITH_TRANSFORM) { this.setTransform(itemElement, rowIndex, colIndex, { x, y }, null); diff --git a/source/src/components/HeadlessTable/index.tsx b/source/src/components/HeadlessTable/index.tsx index d0e85b9c..3d3da8de 100644 --- a/source/src/components/HeadlessTable/index.tsx +++ b/source/src/components/HeadlessTable/index.tsx @@ -34,6 +34,7 @@ import { join } from '../../utils/join'; export type HeadlessTableProps = { scrollerDOMRef?: MutableRefObject; + wrapRowsHorizontally?: boolean; brain: MatrixBrain; debugId?: string; activeCellRowHeight: number | ((rowIndex: number) => number) | undefined; @@ -162,6 +163,7 @@ export function HeadlessTable( activeRowIndex, activeCellIndex, onRenderUpdater, + wrapRowsHorizontally, ...domProps } = props; @@ -197,7 +199,7 @@ export function HeadlessTable( const remove = setupResizeObserver(node, onResize, { debounce: 50 }); return remove; - }, []); + }, [wrapRowsHorizontally]); const onContainerScroll = useCallback( (scrollPos: ScrollPosition) => { @@ -210,10 +212,10 @@ export function HeadlessTable( useEffect(() => { const removeOnRenderCount = brain.onRenderCountChange(() => { - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); }); - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); return removeOnRenderCount; }, [brain]); @@ -237,13 +239,17 @@ export function HeadlessTable( brain={brain} cellHoverClassNames={cellHoverClassNames} /> - + {activeCellIndex != null ? ( + + ) : null} - + {activeRowIndex != null ? ( + + ) : null} (props: GroupResizeHandleProps) { height: currentSize.height, }; - props.brain.setAvailableSize(newSize); + props.brain.update(newSize); initialMove = false; } diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts index de99c215..87fc0486 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts @@ -65,6 +65,7 @@ export interface InfiniteTableColumnCellProps groupRenderStrategy: InfiniteTablePropGroupRenderStrategy; getData: () => InfiniteTableRowInfo[]; toggleGroupRow: InfiniteTableToggleGroupRowFn; + rowIndexInPage: number | null; rowIndex: number; rowHeight: number; cellStyle?: InfiniteTablePropCellStyle; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx index 4b452bac..fba5268f 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx @@ -142,6 +142,8 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { rowStyle, rowClassName, + rowIndexInPage, + getData, cellStyle, cellClassName, @@ -543,7 +545,9 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { ); const odd = - (rowInfo.indexInAll != null ? rowInfo.indexInAll : rowIndex) % 2 === 1; + rowIndexInPage != null + ? rowIndexInPage % 2 === 1 + : (rowInfo.indexInAll != null ? rowInfo.indexInAll : rowIndex) % 2 === 1; const zebra = showZebraRows ? (odd ? 'odd' : 'even') : false; diff --git a/source/src/components/InfiniteTable/hooks/useCellRendering.tsx b/source/src/components/InfiniteTable/hooks/useCellRendering.tsx index 662e9572..f24aa112 100644 --- a/source/src/components/InfiniteTable/hooks/useCellRendering.tsx +++ b/source/src/components/InfiniteTable/hooks/useCellRendering.tsx @@ -88,6 +88,7 @@ export function useCellRendering( onScrollToBottom, onScrollStop, scrollToBottomOffset, + wrapRowsHorizontally, ready, } = state; @@ -231,11 +232,16 @@ export function useCellRendering( : 'collapsed'; } + const rowIndexInPage = wrapRowsHorizontally + ? brain.getRowIndexInPage(rowIndex) + : null; + const cellProps: InfiniteTableColumnCellProps = { getData, virtualized: true, showZebraRows, groupRenderStrategy, + rowIndexInPage, rowIndex, rowInfo, hidden, @@ -269,8 +275,10 @@ export function useCellRendering( computedColumnsMap, fieldsToColumn, groupRenderStrategy, + wrapRowsHorizontally, toggleGroupRow, showZebraRows, + brain, repaintId, rowStyle, rowClassName, diff --git a/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts b/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts index 36305d7d..27672ffa 100644 --- a/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts +++ b/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts @@ -217,7 +217,7 @@ export const useColumnPointerEvents = ({ dragger.stop(); - brain.setAvailableSize({ + brain.update({ ...initialAvailableSize, }); diff --git a/source/src/components/InfiniteTable/index.tsx b/source/src/components/InfiniteTable/index.tsx index 36d33ba0..f90b6b07 100644 --- a/source/src/components/InfiniteTable/index.tsx +++ b/source/src/components/InfiniteTable/index.tsx @@ -173,6 +173,7 @@ function InfiniteTableBody() { activeCellIndex, rowDetailRenderer, showHoverRows, + wrapRowsHorizontally, } = componentState; const LoadMaskCmp = components?.LoadMask ?? LoadMask; @@ -263,6 +264,7 @@ function InfiniteTableBody() { } scrollStopDelay={scrollStopDelay} renderer={renderer} + wrapRowsHorizontally={wrapRowsHorizontally} onRenderUpdater={onRenderUpdater} brain={brain} activeCellRowHeight={activeCellRowHeight} diff --git a/source/src/components/InfiniteTable/state/getInitialState.ts b/source/src/components/InfiniteTable/state/getInitialState.ts index 13a8933f..2c2f1b56 100644 --- a/source/src/components/InfiniteTable/state/getInitialState.ts +++ b/source/src/components/InfiniteTable/state/getInitialState.ts @@ -42,6 +42,8 @@ import { import { computeColumnGroupsDepths } from './computeColumnGroupsDepths'; import { getRowDetailRendererFromComponent } from './rowDetailRendererFromComponent'; +import { HorizontalLayoutMatrixBrain } from '../../VirtualBrain/HorizontalLayoutMatrixBrain'; +import { HorizontalLayoutTableRenderer } from '../../HeadlessTable/HorizontalLayoutTableRenderer'; const EMPTY_OBJECT = {}; @@ -60,6 +62,23 @@ function createRenderer(brain: MatrixBrain) { }; } +function createHorizontalRenderer(brain: MatrixBrain) { + const renderer = new HorizontalLayoutTableRenderer( + brain as HorizontalLayoutMatrixBrain, + ); + const onRenderUpdater = buildSubscriptionCallback(); + + brain.onDestroy(() => { + renderer.destroy(); + onRenderUpdater.destroy(); + }); + + return { + renderer, + onRenderUpdater, + }; +} + export function getCellSelector(cellPosition?: CellPositionByIndex) { const selector = `.${InfiniteTableColumnCellClassName}[data-row-index${ cellPosition ? `="${cellPosition.rowIndex}"` : '' @@ -74,15 +93,19 @@ export function getCellSelector(cellPosition?: CellPositionByIndex) { */ export function initSetupState({ debugId, + wrapRowsHorizontally, }: { debugId?: string; + wrapRowsHorizontally?: boolean; }): InfiniteTableSetupState { const columnsGeneratedForGrouping: InfiniteTablePropColumns = {}; /** * This is the main virtualization brain that powers the table */ - const brain = new MatrixBrain(debugId); + const brain = !wrapRowsHorizontally + ? new MatrixBrain(debugId) + : new HorizontalLayoutMatrixBrain(debugId); /** * The brain that virtualises the header is different from the main brain @@ -94,10 +117,10 @@ export function initSetupState({ // however, we sync the headerBrain with the main brain // on horizontal scrolling brain.onScroll((scrollPosition) => { - headerBrain.setScrollPosition({ - scrollLeft: scrollPosition.scrollLeft, - scrollTop: 0, - }); + // headerBrain.setScrollPosition({ + // scrollLeft: scrollPosition.scrollLeft, + // scrollTop: 0, + // }); }); if (__DEV__) { @@ -105,11 +128,13 @@ export function initSetupState({ (globalThis as any).headerBrain = headerBrain; } - const { renderer, onRenderUpdater } = createRenderer(brain); + const { renderer, onRenderUpdater } = !wrapRowsHorizontally + ? createRenderer(brain) + : createHorizontalRenderer(brain); // and on width changes brain.onAvailableSizeChange((size) => { - headerBrain.setAvailableSize({ width: size.width }); + headerBrain.update({ width: size.width }); }); if (__DEV__) { @@ -229,6 +254,8 @@ export const forwardProps = ( onContextMenu: 1, onCellContextMenu: 1, + wrapRowsHorizontally: 1, + onRenderRangeChange: 1, onScrollToTop: 1, diff --git a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts index 0298a6e1..df324e1e 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts @@ -597,6 +597,8 @@ export interface InfiniteTableProps { loadingText?: Renderable; components?: InfiniteTablePropComponents; + wrapRowsHorizontally?: boolean; + keyboardShortcuts?: InfiniteTablePropKeyboardShorcut[]; viewportReservedWidth?: number; diff --git a/source/src/components/InfiniteTable/types/InfiniteTableState.ts b/source/src/components/InfiniteTable/types/InfiniteTableState.ts index 83150bab..30dd8ef8 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableState.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableState.ts @@ -161,6 +161,8 @@ export interface InfiniteTableMappedState { onKeyDown: InfiniteTableProps['onKeyDown']; onCellClick: InfiniteTableProps['onCellClick']; + wrapRowsHorizontally: InfiniteTableProps['wrapRowsHorizontally']; + rowDetailCache: RowDetailCache; headerOptions: NonUndefined['headerOptions']>; diff --git a/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts b/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts new file mode 100644 index 00000000..51b418e2 --- /dev/null +++ b/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts @@ -0,0 +1,335 @@ +import { + ALL_DIRECTIONS, + IBrain, + ItemSizeFunction, + WhichDirection, +} from './IBrain'; + +import { MatrixBrain, MatrixBrainOptions } from './MatrixBrain'; + +/** + * + * A Horizontal layout brain is a variation of the matrix brain. + * + * It's a matrix brain that will only have rows that fit in the viewport + * while repeating the columns multiple times. + * Basically all rows outside the viewport are wrapped and brought horizontally, + * after the initial column set. + * + * Say we have 4 cols and 24 rows, and the viewport can only fit 10 rows without vertical scrollbar. + * + * This means the first 10 rows are displayed as is, + * then the next 10 rows are wrapped horizontally, thus creating another 4 columns + * (identical to the first ones) + * and then the last 4 rows are wrapped again, thus another 4 cols are created. + * + * So we have a total of 24 rows and 12 columns - although physically, there are only 10 rows. + * + * It's still a matrix, so the matrix brain should work its way and the algorithm is the same + * after we enforce the correct number of physical rows and columns (10 and 24 respectively + * in the example above). + * + * + * Let's take another, more simple example, + * + * col0|col1|col2 col0'|col1'|col2'| + * +----+----+----++------+-----+-----| + * 0| 0,0| 0,1|0,2 || 3,0 | 3,1 | 3,2 | + * 1| 1,0| 1,1|1,2 || 4,0 | 4,1 | 4,2 | + * 2| 2,0| 2,1|2,2 || 5,0 | 5,1 | 5,2 | + * +----+----+----||------------------- + * + * + * Now imagine that we have scrolling and only col1 to col1' are in the viewport + * + * this gives us the following render range: + * rows 0 to 2, with col1 and col2 + * rows 3 to 4, with col0' and col1' + * + * so if we were to unwrap and put those rows vertically, + * we won't have a contiguous render range like we do for the normal matrix brain. + * + * I mean we still have a valid matrix brain range, which would be + * rows 0 to 2 and col1 to col1', so start:[0,1], end: [3,5] + * but this is in the normal matrix, but when we unwrap, it's no longer continuous + * but rather we have two render ranges: + * start: [0,1], end: [3,3] so + * |col1|col2 + * +----+----+ + * 0| 0,1|0,2 | + * 1| 1,1|1,2 | + * 2| 2,1|2,2 | + * +----+----+----| + * start: [3,0], end: [6,3] so + * col0'|col1'| + * +-----+-----+ + * 3| 3,0 | 3,1 | + * 4| 4,0 | 4,1 | + * 5| 5,0 | 5,1 | + * +-----+-----+ + * + * + * SO: we need a way to translate a cell position from MATRIX RANGE to HORIZONTAL LAYOUT RANGE. + * + * This is what + * - getMatrixCoordinatesForHorizontalLayoutPosition and + * - getHorizontalLayoutPositionFromMatrixCoordinates + * do! + * + */ + +export class HorizontalLayoutMatrixBrain extends MatrixBrain implements IBrain { + public visiblePageCount = 0; + private _rowsPerPage = 0; + + private totalPageCount: number = 0; + public pageWidth: number = 0; + + public initialCols = 0; + public initialRows = 0; + private initialColWidth: MatrixBrainOptions['colWidth'] = 0; + protected colWidth: ItemSizeFunction = () => 10; + + constructor(name?: string) { + super(`HorizontalLayout${name ? `:${name}` : ''}`); + } + + getRowIndexInPage(rowIndex: number) { + return this.rowsPerPage ? rowIndex % this.rowsPerPage : rowIndex; + } + + set rowsPerPage(rowsPerPage: number) { + if (rowsPerPage != this._rowsPerPage) { + this._rowsPerPage = rowsPerPage; + } + } + + get rowsPerPage() { + return this._rowsPerPage; + } + + public getMatrixCoordinatesForHorizontalLayoutPosition(pos: { + rowIndex: number; + colIndex: number; + }) { + let rowIndex = pos.rowIndex; + let colIndex = pos.colIndex; + if (pos.rowIndex >= this.rowsPerPage && this.rowsPerPage > 0) { + rowIndex = pos.rowIndex % this.rowsPerPage; + + const pageIndex = Math.floor(pos.rowIndex / this.rowsPerPage); + colIndex = pageIndex * this.initialCols + colIndex; + } + + return { + rowIndex, + colIndex, + }; + } + + public getHorizontalLayoutPositionFromMatrixCoordinates(pos: { + rowIndex: number; + colIndex: number; + }) { + let rowIndex = pos.rowIndex; + let colIndex = pos.colIndex; + + if (pos.colIndex >= this.initialCols && this.initialCols > 0) { + const pageIndex = Math.floor(pos.colIndex / this.initialCols); + + colIndex = pos.colIndex - pageIndex * this.initialCols; + rowIndex = this.rowsPerPage * pageIndex + pos.rowIndex; + } + return { + colIndex, + rowIndex, + }; + } + + public xgetCellOffset = (rowIndex: number, colIndex: number) => { + let { x, y } = super.getCellOffset(rowIndex, colIndex); + const rowHeight = this.rowHeight; + if (typeof rowHeight !== 'number') { + throw new Error('rowHeight must be a number'); + } + if (rowIndex >= this.rowsPerPage && this.rowsPerPage > 0) { + const rowIndexInPage = rowIndex % this.rowsPerPage; + + y = rowIndexInPage * rowHeight; + + const pageIndex = Math.floor(rowIndex / this.rowsPerPage); + const pageOffset = pageIndex ? pageIndex * this.pageWidth : 0; + + x += pageOffset; + } + + if (rowIndex === 4 && colIndex === 0) { + console.log( + 'cell offset row' + rowIndex + '-col' + colIndex, + { rowIndex: rowIndex, colIndex: colIndex }, + { x, y }, + ); + } + return { x, y }; + }; + + public update(options: Partial) { + const { + rows, + cols, + rowHeight, + colWidth, + width: availableWidth, + height: availableHeight, + } = options; + + const widthDefined = typeof availableWidth === 'number'; + const heightDefined = typeof availableHeight === 'number'; + + const widthChanged = widthDefined && availableWidth !== this.availableWidth; + const heightChanged = + heightDefined && availableHeight !== this.availableHeight; + + if (widthChanged) { + this.availableWidth = availableWidth; + this.availableRenderWidth = availableWidth; + } + if (heightChanged) { + this.availableHeight = availableHeight; + this.availableRenderHeight = availableHeight; + } + + if (widthChanged || heightChanged) { + this.notifyAvailableSizeChange(); + } + + const rowsDefined = typeof rows === 'number'; + const colsDefined = typeof cols === 'number'; + + const rowsChanged = rowsDefined && rows !== this.initialRows; + const colsChanged = colsDefined && cols !== this.initialCols; + + if (rowsDefined && rowsChanged) { + this.initialRows = rows; + this.rows = rows; + console.log('rows defined', rows); + } + if (colsDefined && colsChanged) { + this.initialCols = cols; + this.cols = cols; + } + + const rowHeightDefined = rowHeight != null; + const colWidthDefined = colWidth != null; + + const rowHeightChanged = rowHeightDefined && rowHeight !== this.rowHeight; + const colWidthChanged = + colWidthDefined && colWidth !== this.initialColWidth; + + if (rowHeightDefined) { + this.rowHeight = rowHeight; + } + if (colWidthDefined) { + this.initialColWidth = colWidth; + this.colWidth = this.getColWidth; + } + + if (__DEV__) { + if (widthChanged) { + this.debug( + 'New available width %d (size is %d,%d)', + this.availableWidth, + this.availableWidth, + this.availableHeight, + ); + } + if (heightChanged) { + this.debug( + 'New available height %d (size is %d,%d)', + this.availableHeight, + this.availableWidth, + this.availableHeight, + ); + } + + if (rowsChanged) { + this.debug('New rows count: %d', this.rows); + } + if (colsChanged) { + this.debug('New cols count: %d', this.cols); + } + if (rowHeightChanged) { + this.debug('New row size', this.rowHeight); + } + if (colWidthChanged) { + this.debug('New col size', this.colWidth); + } + } + + const horizontalChange = colsChanged || colWidthChanged || widthChanged; + const verticalChange = rowsChanged || rowHeightChanged || heightChanged; + + if (horizontalChange || verticalChange) { + this.updateRenderCount({ + horizontal: horizontalChange, + vertical: verticalChange, + }); + } + } + + getColWidth = (colIndex: number) => { + if (typeof this.initialColWidth === 'number') { + return this.initialColWidth; + } + + return this.initialColWidth(colIndex % this.initialCols); + }; + doUpdateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { + if (typeof this.rowHeight !== 'number') { + throw new Error('rowHeight must be a number'); + } + + // determine the width of a column-set (or page) + + let pageWidth = 0; + for (let i = 0; i < this.initialCols; i++) { + pageWidth += this.getColWidth(i); + } + this.pageWidth = pageWidth; + + // based on the page width, determine the number of rows per page + this.rowsPerPage = Math.floor(this.availableHeight / this.rowHeight); + + this.totalPageCount = this.rowsPerPage + ? Math.ceil(this.initialRows / this.rowsPerPage) + : 0; + this.visiblePageCount = this.totalPageCount + ? Math.max(Math.ceil(this.availableWidth / this.pageWidth), 1) + : 1; + + this.availableRenderHeight = + this.visiblePageCount * this.rowsPerPage * this.rowHeight; + + console.log( + 'visiblePageCount', + this.visiblePageCount, + 'initialCols', + this.initialCols, + ); + this.cols = this.totalPageCount * this.initialCols; + this.rows = this.rowsPerPage; + + super.doUpdateRenderCount(which); + } + + getVirtualizedContentSizeFor(direction: 'horizontal' | 'vertical') { + if (direction === 'vertical') { + if (typeof this.rowHeight !== 'number') { + throw new Error('rowHeight must be a number'); + } + return this.rowHeight * this.rowsPerPage; + } + + return this.pageWidth * this.totalPageCount; + } +} diff --git a/source/src/components/VirtualBrain/IBrain.ts b/source/src/components/VirtualBrain/IBrain.ts new file mode 100644 index 00000000..b583debd --- /dev/null +++ b/source/src/components/VirtualBrain/IBrain.ts @@ -0,0 +1,111 @@ +import { OnScrollFn, ScrollPosition } from '../types/ScrollPosition'; +import { Size } from '../types/Size'; + +export type TableRenderRange = { + start: [number, number]; + end: [number, number]; + rowFixed?: FixedPosition; + colFixed?: FixedPosition; +}; +export type FixedPosition = false | 'start' | 'end'; + +export type WhichDirection = { horizontal?: boolean; vertical?: boolean }; + +export const SORT_ASC = (a: number, b: number) => a - b; + +export const ALL_DIRECTIONS: WhichDirection = { + horizontal: true, + vertical: true, +}; + +export type ItemSizeFunction = (index: number) => number; +export interface IBrain { + getColCount: () => number; + getRowCount: () => number; + + getFixedCellInfo: () => { + fixedRowsStart: number; + fixedColsStart: number; + fixedRowsEnd: number; + fixedColsEnd: number; + }; + + getRowspanParent: (rowIndex: number, colIndex: number) => number; + getColspanParent: (rowIndex: number, colIndex: number) => number; + + getRowHeight: (rowIndex: number) => number; + getColWidth: (colIndex: number) => number; + + getRowHeightWithSpan: ( + rowIndex: number, + colIndex: number, + rowspan: number, + ) => number; + + getColWidthWithSpan: ( + rowIndex: number, + colIndex: number, + colspan: number, + ) => number; + + isRowFixedStart: (rowIndex: number) => boolean; + isRowFixedEnd: (rowIndex: number) => boolean; + + getScrollPosition: () => ScrollPosition; + getItemOffsetFor: ( + itemIndex: number, + direction: 'horizontal' | 'vertical', + ) => number; + + getItemSize: ( + itemIndex: number, + direction: 'horizontal' | 'vertical', + ) => number; + + getItemAt: ( + scrollPos: number, + direction: 'horizontal' | 'vertical', + ) => number; + + getAvailableSize: () => Size; + + getFixedStartRowsHeight: () => number; + getFixedEndRowsHeight: (options?: { skipScroll: boolean }) => number; + + getFixedStartColsWidth: () => number; + getFixedEndColsWidth: (options?: { skipScroll: boolean }) => number; + + getFixedEndColsOffsets: (options?: { skipScroll: boolean }) => number[]; + getFixedEndRowsOffsets: (options?: { skipScroll: boolean }) => number[]; + + isRowFixed: (rowIndex: number) => boolean; + isColFixed: (colIndex: number) => boolean; + + getRenderRange: () => TableRenderRange; + + getExtraSpanCellsForRange: (options: { + horizontal: { startIndex: number; endIndex: number }; + vertical: { + startIndex: number; + endIndex: number; + }; + }) => [number, number][]; + + getRowspan: (rowIndex: number, colIndex: number) => number; + getColspan: (rowIndex: number, colIndex: number) => number; + + getCellOffset: ( + rowIndex: number, + colIndex: number, + ) => { x: number; y: number }; + + name: string; + onRenderRangeChange: ( + fn: (renderRange: TableRenderRange) => void, + ) => VoidFunction; + onScroll: (fn: OnScrollFn) => VoidFunction; + onAvailableSizeChange: (fn: (size: Size) => void) => VoidFunction; + onDestroy: (fn: VoidFunction) => void; + onScrollStart: (fn: VoidFunction) => VoidFunction; + onScrollStop: (fn: (scrollPos: ScrollPosition) => void) => VoidFunction; +} diff --git a/source/src/components/VirtualBrain/MatrixBrain.ts b/source/src/components/VirtualBrain/MatrixBrain.ts index 62106c9a..bc744bc6 100644 --- a/source/src/components/VirtualBrain/MatrixBrain.ts +++ b/source/src/components/VirtualBrain/MatrixBrain.ts @@ -4,8 +4,17 @@ import { Logger } from '../../utils/debug'; import type { OnScrollFn, ScrollPosition } from '../types/ScrollPosition'; import type { Size } from '../types/Size'; import type { VoidFn } from '../types/VoidFn'; +import type { + IBrain, + TableRenderRange, + FixedPosition, + WhichDirection, + ItemSizeFunction, +} from './IBrain'; -export type FixedPosition = false | 'start' | 'end'; +import { SORT_ASC, ALL_DIRECTIONS } from './IBrain'; + +export type { FixedPosition }; export type SpanFunction = ({ rowIndex, colIndex, @@ -19,8 +28,6 @@ type RenderRangeType = { endIndex: number; }; -type ItemSizeFunction = (index: number) => number; - export type MatrixBrainOptions = { width: number; height: number; @@ -35,12 +42,7 @@ export type MatrixBrainOptions = { colspan?: SpanFunction; }; -export type TableRenderRange = { - start: [number, number]; - end: [number, number]; - rowFixed?: FixedPosition; - colFixed?: FixedPosition; -}; +export type { TableRenderRange }; export const getRenderRangeCellCount = (range: TableRenderRange) => { const { start, end } = range; @@ -63,11 +65,6 @@ export const getRenderRangeRowCount = (range: TableRenderRange) => { return rowCount; }; -type WhichDirection = { horizontal?: boolean; vertical?: boolean }; - -const ALL_DIRECTIONS: WhichDirection = { horizontal: true, vertical: true }; -const SORT_ASC = (a: number, b: number) => a - b; - const raf = typeof window !== 'undefined' ? requestAnimationFrame @@ -93,18 +90,42 @@ export type FnOnScrollStop = ( range: TableRenderRange, ) => void; -export class MatrixBrain extends Logger { +export type ShouldUpdateRenderCountOptions = { + horizontalChange: boolean; + + colsChanged: boolean; + colWidthChanged: boolean; + widthChanged: boolean; + colspanChanged: boolean; + + verticalChange: boolean; + + rowsChanged: boolean; + rowHeightChanged: boolean; + heightChanged: boolean; + rowspanChanged: boolean; +}; + +function defaultShouldUpdateRenderCount( + options: ShouldUpdateRenderCountOptions, +) { + return options.horizontalChange || options.verticalChange; +} + +export class MatrixBrain extends Logger implements IBrain { private scrolling = false; - private width: MatrixBrainOptions['width'] = 0; + protected availableWidth: MatrixBrainOptions['width'] = 0; + protected availableRenderWidth: number = 0; public name: string = ''; - private height: MatrixBrainOptions['height'] = 0; + protected availableHeight: MatrixBrainOptions['height'] = 0; + protected availableRenderHeight: number = 0; - private cols: MatrixBrainOptions['cols'] = 0; - private rows: MatrixBrainOptions['rows'] = 0; + protected cols: MatrixBrainOptions['cols'] = 0; + protected rows: MatrixBrainOptions['rows'] = 0; - private rowHeight: MatrixBrainOptions['rowHeight'] = 0; - private colWidth: MatrixBrainOptions['colWidth'] = 0; + protected rowHeight: MatrixBrainOptions['rowHeight'] = 0; + protected colWidth: MatrixBrainOptions['colWidth'] = 0; private rowspan: MatrixBrainOptions['rowspan']; private colspan: MatrixBrainOptions['colspan']; @@ -112,16 +133,16 @@ export class MatrixBrain extends Logger { private rowspanParent!: Map; private rowspanValue!: Map; - private rowHeightCache!: number[]; - private rowOffsetCache!: number[]; + protected rowHeightCache!: number[]; + protected rowOffsetCache!: number[]; private verticalTotalSize = 0; private colspanParent!: Map; private colspanValue!: Map; - private colWidthCache!: number[]; - private colOffsetCache!: number[]; + protected colWidthCache!: number[]; + protected colOffsetCache!: number[]; private horizontalTotalSize = 0; private horizontalRenderCount?: number = undefined; @@ -178,6 +199,11 @@ export class MatrixBrain extends Logger { const logName = `MatrixBrain${name ? `:${name}` : ''}`; super(logName); this.name = logName; + + this.update = this.update.bind(this); + + this.getCellOffset = this.getCellOffset.bind(this); + this.reset(); } @@ -221,16 +247,40 @@ export class MatrixBrain extends Logger { return this.cols; }; - public update = (options: Partial) => { - const { rows, cols, rowHeight, colWidth, width, height } = options; + public update( + options: Partial, + shouldUpdateRenderCount?: ( + options: ShouldUpdateRenderCountOptions, + ) => boolean, + ) { + const { + rows, + cols, + rowHeight, + colWidth, + width: availableWidth, + height: availableHeight, + } = options; - const widthDefined = typeof width === 'number'; - const heightDefined = typeof height === 'number'; + const widthDefined = typeof availableWidth === 'number'; + const heightDefined = typeof availableHeight === 'number'; - const widthChanged = widthDefined && width !== this.width; - const heightChanged = heightDefined && height !== this.height; + const widthChanged = widthDefined && availableWidth !== this.availableWidth; + const heightChanged = + heightDefined && availableHeight !== this.availableHeight; - this.setAvailableSize({ width, height }, { skipUpdateRenderCount: true }); + if (widthChanged) { + this.availableWidth = availableWidth; + this.availableRenderWidth = availableWidth; + } + if (heightChanged) { + this.availableHeight = availableHeight; + this.availableRenderHeight = availableHeight; + } + + if (widthChanged || heightChanged) { + this.notifyAvailableSizeChange(); + } const rowsDefined = typeof rows === 'number'; const colsDefined = typeof cols === 'number'; @@ -262,17 +312,17 @@ export class MatrixBrain extends Logger { if (widthChanged) { this.debug( 'New available width %d (size is %d,%d)', - this.width, - this.width, - this.height, + this.availableWidth, + this.availableWidth, + this.availableHeight, ); } if (heightChanged) { this.debug( 'New available height %d (size is %d,%d)', - this.height, - this.width, - this.height, + this.availableHeight, + this.availableWidth, + this.availableHeight, ); } @@ -308,82 +358,31 @@ export class MatrixBrain extends Logger { const verticalChange = rowsChanged || rowHeightChanged || heightChanged || rowspanChanged; - if (horizontalChange || verticalChange) { + const shouldUpdateFn = + shouldUpdateRenderCount || defaultShouldUpdateRenderCount; + + if ( + shouldUpdateFn({ + horizontalChange, + verticalChange, + colsChanged, + colWidthChanged, + widthChanged, + colspanChanged, + rowsChanged, + rowHeightChanged, + heightChanged, + rowspanChanged, + }) + ) { this.updateRenderCount({ horizontal: horizontalChange, vertical: verticalChange, }); } - }; - - public setRowAndColumnSizes({ - rowHeight, - colWidth, - }: { - rowHeight: number | ItemSizeFunction; - colWidth: number | ItemSizeFunction; - }) { - const horizontalSame = colWidth === this.colWidth; - const verticalSame = rowHeight === this.rowHeight; - - this.rowHeight = rowHeight; - this.colWidth = colWidth; - - this.updateRenderCount({ - horizontal: !horizontalSame, - vertical: !verticalSame, - }); - } - - public setRowsAndCols = ({ rows, cols }: { rows: number; cols: number }) => { - const rowsSame = rows === this.rows; - const colsSame = cols === this.cols; - - this.rows = rows; - this.cols = cols; - - this.updateRenderCount({ - horizontal: !colsSame, - vertical: !rowsSame, - }); - }; - - public setAvailableSize( - size: Partial, - config?: { skipUpdateRenderCount?: boolean }, - ) { - let { width, height } = size; - - width = width ?? this.width; - height = height ?? this.height; - - const widthSame = width === this.width; - const heightSame = height === this.height; - - if (widthSame && heightSame) { - return; - } - this.width = width; - this.height = height; - - if (__DEV__) { - this.debug( - 'New available size: width %d, height %d', - this.width, - this.height, - ); - } - - this.notifyAvailableSizeChange(); - - if (config && config.skipUpdateRenderCount) { - return; - } - - this.updateRenderCount({ horizontal: !widthSame, vertical: !heightSame }); } - updateRenderCount = (which: WhichDirection = ALL_DIRECTIONS) => { + protected updateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { // if (this._updateRenderCountRafId) { // cancelAnimationFrame(this._updateRenderCountRafId); // } @@ -393,23 +392,23 @@ export class MatrixBrain extends Logger { // delete this._updateRenderCountRafId; // }); - }; + } - private doUpdateRenderCount = (which: WhichDirection = ALL_DIRECTIONS) => { - if (!this.width || !this.height) { + protected doUpdateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { + if (!this.availableWidth || !this.availableHeight) { this.setRenderCount({ horizontal: 0, vertical: 0 }); } this.setRenderCount(this.computeRenderCount(which)); - }; + } get scrollTopMax() { - const totalSize = this.getTotalSize(); - return totalSize.height - this.height; + const totalSize = this.getVirtualizedContentSize(); + return totalSize.height - this.availableHeight; } get scrollLeftMax() { - const totalSize = this.getTotalSize(); - return totalSize.width - this.width; + const totalSize = this.getVirtualizedContentSize(); + return totalSize.width - this.availableWidth; } private setScrolling = (scrolling: boolean) => { @@ -458,10 +457,10 @@ export class MatrixBrain extends Logger { } }; - public setScrollPosition = ( + public setScrollPosition( scrollPosition: ScrollPosition, callback?: (scrollPos: ScrollPosition) => void, - ) => { + ) { this.setScrolling(true); const changeHorizontal = scrollPosition.scrollLeft !== this.scrollPosition.scrollLeft; @@ -479,9 +478,9 @@ export class MatrixBrain extends Logger { this.notifyScrollChange(); } - }; + } - private notifyAvailableSizeChange = () => { + protected notifyAvailableSizeChange = () => { if (this.destroyed) { return; } @@ -505,7 +504,7 @@ export class MatrixBrain extends Logger { }); }; - private notifyRenderRangeChange = () => { + protected notifyRenderRangeChange() { if (this.destroyed) { return; } @@ -524,7 +523,7 @@ export class MatrixBrain extends Logger { } }); }); - }; + } private notifyVerticalRenderRangeChange = () => { if (this.destroyed) { return; @@ -582,11 +581,12 @@ export class MatrixBrain extends Logger { direction: 'horizontal' | 'vertical', itemSize: number | ItemSizeFunction, count: number, - theSize: Size, + theRenderSize: Size, ) => { let renderCount = 0; - let size = direction === 'horizontal' ? theSize.width : theSize.height; + let size = + direction === 'horizontal' ? theRenderSize.width : theRenderSize.height; size -= this.getFixedSize(direction); @@ -842,7 +842,7 @@ export class MatrixBrain extends Logger { 'horizontal', this.colWidth, this.cols, - this.getAvailableSize(), + this.getAvailableRenderSize(), ); } if (recomputeVertical) { @@ -850,7 +850,7 @@ export class MatrixBrain extends Logger { 'vertical', this.rowHeight, this.rows, - this.getAvailableSize(), + this.getAvailableRenderSize(), ); } const result = { @@ -1134,17 +1134,17 @@ export class MatrixBrain extends Logger { return 0; }; - public getCellOffset = (rowIndex: number, colIndex: number) => { + public getCellOffset(rowIndex: number, colIndex: number) { return { x: this.getItemOffsetFor(colIndex, 'horizontal'), y: this.getItemOffsetFor(rowIndex, 'vertical'), }; - }; + } - public getItemOffsetFor = ( + public getItemOffsetFor( itemIndex: number, direction: 'horizontal' | 'vertical', - ): number => { + ): number { const itemSize = direction === 'horizontal' ? this.colWidth : this.rowHeight; if (typeof itemSize !== 'function') { @@ -1168,9 +1168,9 @@ export class MatrixBrain extends Logger { result = itemOffsetCache[itemIndex]; } return result; - }; + } - private computeCacheFor = ( + protected computeCacheFor = ( itemIndex: number, direction: 'horizontal' | 'vertical', ) => { @@ -1432,14 +1432,18 @@ export class MatrixBrain extends Logger { return cachedSize; }; - getTotalSize = () => { + getRowIndexInPage(rowIndex: number) { + return rowIndex; + } + + getVirtualizedContentSize() { return { - height: this.getTotalSizeFor('vertical'), - width: this.getTotalSizeFor('horizontal'), + height: this.getVirtualizedContentSizeFor('vertical'), + width: this.getVirtualizedContentSizeFor('horizontal'), }; - }; + } - public getTotalSizeFor = (direction: 'horizontal' | 'vertical') => { + public getVirtualizedContentSizeFor(direction: 'horizontal' | 'vertical') { const count = direction === 'horizontal' ? this.cols : this.rows; const itemSize = direction === 'horizontal' ? this.colWidth : this.rowHeight; @@ -1471,7 +1475,7 @@ export class MatrixBrain extends Logger { } return result; - }; + } setRenderRange = ({ horizontal, @@ -1602,8 +1606,15 @@ export class MatrixBrain extends Logger { public getAvailableSize = () => { return { - width: this.width, - height: this.height, + width: this.availableWidth, + height: this.availableHeight, + }; + }; + + protected getAvailableRenderSize = () => { + return { + width: this.availableRenderWidth ?? this.availableWidth, + height: this.availableRenderHeight ?? this.availableHeight, }; };