diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e872158cf..7078d4c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: paths: - 'source/src/**' - 'examples/src/**/*.spec.ts' + - 'examples/src/**/*.page.tsx' concurrency: group: ${{ github.workflow }} diff --git a/examples/src/pages/demos/new-perf-approach.page.tsx b/examples/src/pages/demos/new-perf-approach.page.tsx index 7600e2c6a..d492854d0 100644 --- a/examples/src/pages/demos/new-perf-approach.page.tsx +++ b/examples/src/pages/demos/new-perf-approach.page.tsx @@ -40,7 +40,7 @@ export default function App() { ); }; - const [brain] = React.useState(() => new MatrixBrain()); + const [brain] = React.useState(() => new MatrixBrain('test')); (globalThis as any).brain = brain; @@ -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/demos/new-perf-approach2.page.tsx b/examples/src/pages/demos/new-perf-approach2.page.tsx index 352c73ea9..6dd67b308 100644 --- a/examples/src/pages/demos/new-perf-approach2.page.tsx +++ b/examples/src/pages/demos/new-perf-approach2.page.tsx @@ -39,7 +39,7 @@ export default function App() { ); }; - const [brain] = React.useState(() => new MatrixBrain()); + const [brain] = React.useState(() => new MatrixBrain('test')); (globalThis as any).brain = brain; 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 000000000..a567e26cd --- /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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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('test', { isHeader: false }); + + 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 000000000..f784fc9cd --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/default.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'; +import { Developer, dataSource } from './horiz-layout-data'; + +const columns: InfiniteTablePropColumns = { + id: { + field: 'id', + type: 'number', + /*xdefaultWidth: 80,*/ renderValue: ({ value }) => value - 1, + style: (_options) => { + return { + // background : options.rowInfo. + }; + }, + }, + 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: '90vw' } }; +// const domProps = { style: { height: '30vh', width: 300 } }; + +// 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/example.page.tsx b/examples/src/pages/tests/horizontal-layout/example.page.tsx new file mode 100644 index 000000000..00a4aebba --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/example.page.tsx @@ -0,0 +1,357 @@ +import * as React from 'react'; + +import { + DataSourceApi, + InfiniteTable, + InfiniteTableColumn, + InfiniteTablePropColumns, +} from '@infinite-table/infinite-react'; +import { DataSource } from '@infinite-table/infinite-react'; + +type Developer = { + id: number; + + firstName: string; + lastName: string; + + currency: string; + country: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + + age: number; + salary: number; +}; + +// const COLORS = [ +// `rgb(237,28,36, 15%)`, +// `rgb(132,60,17, 15%)`, +// `rgb(50,14,5, 15%)`, +// ]; + +const style: InfiniteTableColumn['style'] = ( + { + // data, + // rowInfo, + // horizontalLayoutPageIndex, + // rowIndexInHorizontalLayoutPage, + }, +) => { + return { + // background: COLORS[horizontalLayoutPageIndex! % COLORS.length], + }; +}; + +const header: InfiniteTableColumn['header'] = ({ + horizontalLayoutPageIndex, + + column, +}) => { + return ( + + {column.field} - {horizontalLayoutPageIndex}{' '} + {column.computedSortedAsc + ? '📈' + : column.computedSortedDesc + ? '📉' + : '👀'} + + ); +}; + +// const FlashingColumnCell = React.forwardRef( +// (props: React.HTMLProps, ref: React.Ref) => { +// const { domRef, value, column, rowInfo } = +// useInfiniteColumnCell(); + +// const flashBackground = 'blue'; +// const [flash, setFlash] = React.useState(false); + +// const rowId = rowInfo.id; +// const columnId = column.id; +// const prevValueRef = React.useRef({ +// columnId, +// rowId, +// value, +// }); + +// console.log( +// 'render', +// // props.children?.props?.children?[1].props?.children.props.children[3] +// ); + +// React.useEffect(() => { +// const prev = prevValueRef.current; +// if ( +// prev.value !== value && +// prev.rowId === rowId && +// prev.columnId === columnId +// ) { +// console.log('value changed', value, 'prev', prev.value); +// setFlash(true); +// setTimeout(() => { +// setFlash(false); +// }, 500); +// } + +// console.log('value', value); +// prevValueRef.current = { +// columnId: column.id, +// rowId: rowInfo.id, +// value, +// }; +// }, [value, columnId, rowId]); + +// React.useEffect(() => { +// console.log('mount'); +// }, []); + +// return ( +//
+// {props.children} +//
+// ); +// }, +// ); + +// const Flashing = (props: { value: any }) => { +// const value = props.value; + +// const prevValueRef = React.useRef(value); +// const { htmlElementRef } = useInfiniteColumnCell(); +// const flash = () => { +// if (!htmlElementRef.current) { +// return; +// } + +// htmlElementRef.current!.style.backgroundColor = 'red'; +// setTimeout(() => { +// htmlElementRef.current!.style.backgroundColor = ''; +// }, 500); +// }; + +// React.useEffect(() => { +// if (prevValueRef.current !== value) { +// flash(); +// } +// prevValueRef.current = value; +// }, [value]); + +// React.useEffect(() => { +// flash(); +// }, []); + +// return
{props.value}
; +// }; + +const columns: InfiniteTablePropColumns = { + id: { + field: 'id', + type: 'number', + defaultEditable: false, + columnGroup: 'colgroup', + header, + style, + }, + canDesign: { + field: 'canDesign', + columnGroup: 'colgroup', + style, + header, + }, + salary: { + field: 'salary', + type: 'number', + columnGroup: 'colgroup', + style, + header, + // renderValue: ({ value }) => { + // console.log('renderValue', value); + // return value; + // // return ; + // }, + components: { + // ColumnCell: FlashingColumnCell, + // ColumnCell: FlashingColumnCell, + }, + }, + firstName: { + field: 'firstName', + columnGroup: 'colgroup', + style, + }, + age: { + field: 'age', + type: 'number', + columnGroup: 'colgroup', + style, + }, + + stack: { + field: 'stack', + renderMenuIcon: false, + style, + columnGroup: 'colgroup', + }, + currency: { field: 'currency', style, columnGroup: 'colgroup' }, + country: { field: 'country', style, columnGroup: 'colgroup' }, +}; + +// const render: InfiniteTableColumn['render'] = ({ +// rowIndexInHorizontalLayoutPage, +// rowInfo, +// renderBag, +// }) => { +// if (rowInfo.isGroupRow) { +// return ( +// <> +// {renderBag.groupIcon} +// {renderBag.value} +// +// ); +// } +// if (rowIndexInHorizontalLayoutPage === 0) { +// return ( +// <> +// {renderBag.groupIcon} +// {renderBag.value} +// +// ); +// } +// return null; +// }; + +// const renderValue: InfiniteTableColumn['renderValue'] = ({ +// value, +// rowInfo, +// column, +// rowIndexInHorizontalLayoutPage, +// }) => { +// if (rowInfo.isGroupRow) { +// return value; +// } +// if (!rowInfo.dataSourceHasGrouping) { +// return null; +// } +// const groupKeys = rowInfo.groupKeys; +// if ( +// rowIndexInHorizontalLayoutPage == null || +// rowIndexInHorizontalLayoutPage > groupKeys.length +// ) { +// return null; +// } + +// return `(${groupKeys[column.computedVisibleIndex]})`; +// }; +export default () => { + const dataSource = React.useCallback(() => { + return fetch(process.env.NEXT_PUBLIC_BASE_URL + '/developers1k') + .then((r) => r.json()) + .then((data) => { + // data.length = 2; + return data; + // return new Promise((resolve) => { + // setTimeout(() => { + // resolve(data); + // }, 10); + // }); + }); + }, []); + + const [dataSourceApi, setDataSourceApi] = + React.useState | null>(null); + + const [cols, setCols] = React.useState(columns); + const [wrapRowsHorizontally, setWrapRowsHorizontally] = React.useState(true); + return ( + <> + {Object.keys(columns).map((colId) => { + return ( + + ); + })} + + + + + onReady={setDataSourceApi} + data={dataSource} + shouldReloadData={{ + filterValue: false, + }} + // defaultFilterValue={[]} + primaryKey="id" + > + + columns={cols} + // xcolumnGroups={{ + // colgroup: { + // header: ({ horizontalLayoutPageIndex }) => { + // return <>Group {horizontalLayoutPageIndex}; + // }, + // style: ({ horizontalLayoutPageIndex }) => { + // return { + // background: + // COLORS[horizontalLayoutPageIndex! % COLORS.length], + // }; + // }, + // }, + // }} + wrapRowsHorizontally={wrapRowsHorizontally} + columnDefaultWidth={120} + columnDefaultEditable + domProps={{ + style: { + minHeight: '70vh', + // width: '90vw', + }, + }} + /> + + + + ); +}; 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 000000000..5aea8593d --- /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 000000000..e86a770d5 --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/renderer.page.tsx @@ -0,0 +1,79 @@ +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', { + isHeader: false, + }); + }); + + 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 000000000..6653039a2 --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/test.page.tsx @@ -0,0 +1,101 @@ +import { + InfiniteTable, + DataSource, + type InfiniteTablePropColumns, +} from '@infinite-table/infinite-react'; + +import * as React from 'react'; +import { useState } from 'react'; + +type Developer = { + id: number; + + firstName: string; + lastName: string; + + currency: string; + country: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + + age: number; + salary: number; +}; + +const columns: InfiniteTablePropColumns = { + id: { + field: 'id', + type: 'number', + }, + canDesign: { + field: 'canDesign', + }, + salary: { + field: 'salary', + type: 'number', + }, + // firstName: { + // field: 'firstName', + // }, + // age: { + // field: 'age', + // type: 'number', + // }, + + // stack: { field: 'stack', renderMenuIcon: false }, + // currency: { field: 'currency' }, + // country: { field: 'country' }, +}; + +const domProps = { + // style: { height: 420 /*30px header, 420 body*/, width: 230 }, + style: { height: '50vh' /*30px header, 420 body*/, width: '80vw' }, +}; + +const data = Array.from({ length: 50 }, (_, i) => ({ + id: i, + preferredLanguage: `Lang ${i}`, + age: i * 10, + salary: i * 1000, + firstName: `John ${i}`, + lastName: `Doe ${i}`, + currency: `USD ${i}`, + country: `USA ${i}`, + canDesign: i % 2 === 0 ? ('yes' as const) : ('no' as const), + stack: i % 2 === 0 ? 'frontend' : 'backend', +})); + +export default function App() { + const [wrapRowsHorizontally, setWrapRowsHorizontally] = useState(true); + return ( + <> + + + + primaryKey="id" + data={data} + key={`${wrapRowsHorizontally}`} + > + + wrapRowsHorizontally={wrapRowsHorizontally} + rowHeight={50} + domProps={domProps} + header={true} + columnHeaderHeight={30} + columns={columns} + columnDefaultWidth={200} + onCellClick={({ rowIndex, colIndex }) => { + console.log('clicked', rowIndex, colIndex); + }} + /> + + + ); +} diff --git a/examples/src/pages/tests/mapped-cells.spec.ts b/examples/src/pages/tests/mapped-cells.spec.ts index c73651e4f..6cd2af098 100644 --- a/examples/src/pages/tests/mapped-cells.spec.ts +++ b/examples/src/pages/tests/mapped-cells.spec.ts @@ -8,24 +8,24 @@ export default test.describe.parallel('MappedCells', () => { test('renderCellAtElement should work fine', async ({}) => { const cells = new MappedCells(); - cells.renderCellAtElement(0, 0, 0); - cells.renderCellAtElement(0, 1, 1); - cells.renderCellAtElement(0, 2, 2); + cells.renderCellAtElement(0, 0, 0, undefined); + cells.renderCellAtElement(0, 1, 1, undefined); + cells.renderCellAtElement(0, 2, 2, undefined); expect(cells.isCellRendered(0, 2)).toBe(true); expect(cells.isCellRendered(1, 1)).toBe(false); - cells.renderCellAtElement(0, 3, 2); + cells.renderCellAtElement(0, 3, 2, undefined); expect(cells.isCellRendered(0, 2)).toBe(false); }); test('discardElement should work fine', async () => { const cells = new MappedCells(); - cells.renderCellAtElement(0, 0, 0); - cells.renderCellAtElement(1, 1, 1); - cells.renderCellAtElement(2, 2, 2); - cells.renderCellAtElement(3, 3, 3); + cells.renderCellAtElement(0, 0, 0, undefined); + cells.renderCellAtElement(1, 1, 1, undefined); + cells.renderCellAtElement(2, 2, 2, undefined); + cells.renderCellAtElement(3, 3, 3, undefined); cells.discardElement(1); expect(cells.isCellRendered(1, 1)).toBe(false); @@ -34,10 +34,10 @@ export default test.describe.parallel('MappedCells', () => { test('discardCell should work fine', async () => { const cells = new MappedCells(); - cells.renderCellAtElement(0, 0, 0); - cells.renderCellAtElement(1, 1, 1); - cells.renderCellAtElement(2, 2, 2); - cells.renderCellAtElement(3, 3, 3); + cells.renderCellAtElement(0, 0, 0, undefined); + cells.renderCellAtElement(1, 1, 1, undefined); + cells.renderCellAtElement(2, 2, 2, undefined); + cells.renderCellAtElement(3, 3, 3, undefined); cells.discardCell(1, 2); expect(cells.isCellRendered(1, 1)).toBe(true); @@ -45,18 +45,18 @@ export default test.describe.parallel('MappedCells', () => { expect(cells.isCellRendered(1, 1)).toBe(false); expect(cells.isCellRendered(3, 3)).toBe(true); - cells.renderCellAtElement(4, 3, 3); + cells.renderCellAtElement(4, 3, 3, undefined); expect(cells.isCellRendered(3, 3)).toBe(false); }); test('discardElementsStartingWith should work fine', async ({}) => { const cells = new MappedCells(); - cells.renderCellAtElement(0, 0, 0); - cells.renderCellAtElement(0, 1, 1); - cells.renderCellAtElement(0, 2, 2); - cells.renderCellAtElement(1, 0, 3); - cells.renderCellAtElement(1, 1, 4); - cells.renderCellAtElement(1, 2, 5); + cells.renderCellAtElement(0, 0, 0, undefined); + cells.renderCellAtElement(0, 1, 1, undefined); + cells.renderCellAtElement(0, 2, 2, undefined); + cells.renderCellAtElement(1, 0, 3, undefined); + cells.renderCellAtElement(1, 1, 4, undefined); + cells.renderCellAtElement(1, 2, 5, undefined); cells.discardElementsStartingWith(3); @@ -73,17 +73,17 @@ export default test.describe.parallel('MappedCells', () => { test('getElementsOutsideRenderRange should work fine', async ({}) => { const cells = new MappedCells(); - cells.renderCellAtElement(0, 0, 0); - cells.renderCellAtElement(0, 1, 1); - cells.renderCellAtElement(0, 2, 2); - cells.renderCellAtElement(1, 0, 3); - cells.renderCellAtElement(1, 1, 4); - cells.renderCellAtElement(1, 2, 5); - cells.renderCellAtElement(4, 0, 6); - cells.renderCellAtElement(5, 1, 7); - cells.renderCellAtElement(5, 2, 8); - cells.renderCellAtElement(6, 1, 9); - cells.renderCellAtElement(6, 2, 10); + cells.renderCellAtElement(0, 0, 0, undefined); + cells.renderCellAtElement(0, 1, 1, undefined); + cells.renderCellAtElement(0, 2, 2, undefined); + cells.renderCellAtElement(1, 0, 3, undefined); + cells.renderCellAtElement(1, 1, 4, undefined); + cells.renderCellAtElement(1, 2, 5, undefined); + cells.renderCellAtElement(4, 0, 6, undefined); + cells.renderCellAtElement(5, 1, 7, undefined); + cells.renderCellAtElement(5, 2, 8, undefined); + cells.renderCellAtElement(6, 1, 9, undefined); + cells.renderCellAtElement(6, 2, 10, undefined); const elements = cells.getElementsOutsideRenderRange({ start: [1, 1], diff --git a/examples/src/pages/tests/matrix-brain.spec.ts b/examples/src/pages/tests/matrix-brain.spec.ts index 4df513948..3ca70d994 100644 --- a/examples/src/pages/tests/matrix-brain.spec.ts +++ b/examples/src/pages/tests/matrix-brain.spec.ts @@ -26,7 +26,7 @@ export default test.describe.parallel('MatrixBrain', () => { const ROWS = 20; const COLS = 7; - const brain = new MatrixBrain(); + const brain = new MatrixBrain('test'); brain.update({ colWidth: COL_SIZE, @@ -86,7 +86,7 @@ export default test.describe.parallel('MatrixBrain', () => { const ROWS = 20; const COLS = 7; - const brain = new MatrixBrain(); + const brain = new MatrixBrain('test'); brain.update({ colWidth: COL_SIZE, @@ -118,7 +118,7 @@ export default test.describe.parallel('MatrixBrain', () => { const ROWS = 20; const COLS = 7; - const brain = new MatrixBrain(); + const brain = new MatrixBrain('test'); brain.update({ colWidth: COL_SIZE, @@ -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, }); @@ -200,7 +200,7 @@ export default test.describe.parallel('MatrixBrain', () => { const ROWS = 20; const COLS = 7; - const brain = new MatrixBrain(); + const brain = new MatrixBrain('test'); brain.update({ colWidth: COL_SIZE, diff --git a/examples/src/pages/tests/utils/debug.spec.ts b/examples/src/pages/tests/utils/debug.spec.ts index 3e6ddcbe9..b9f36b99a 100644 --- a/examples/src/pages/tests/utils/debug.spec.ts +++ b/examples/src/pages/tests/utils/debug.spec.ts @@ -16,9 +16,9 @@ export default test.describe.parallel('debug', () => { let args: string[] = []; logger1a('testing'); - expect(args).toEqual(['%c[channel1]', 'color: red', 'testing']); + expect(args).toEqual(['%c[channel1]%c %s', 'color: red', '', 'testing']); logger1b('abc'); - expect(args).toEqual(['%c[channel1]', 'color: red', 'abc']); + expect(args).toEqual(['%c[channel1]%c %s', 'color: red', '', 'abc']); // destroying it dettaches the channel from the color logger1b.destroy(); @@ -26,7 +26,7 @@ export default test.describe.parallel('debug', () => { // so a new logger on the same channel will get a new color, if the old one is destroyed const logger1c = debug('channel1'); logger1c('xyz'); - expect(args).toEqual(['%c[channel1]', 'color: green', 'xyz']); + expect(args).toEqual(['%c[channel1]%c %s', 'color: green', '', 'xyz']); }); test('should allow setting logFn for a channel', () => { @@ -39,7 +39,7 @@ export default test.describe.parallel('debug', () => { const logger1 = debug('channel1'); logger1('testing'); - expect(args).toEqual(['%c[channel1]', 'color: red', 'testing']); + expect(args).toEqual(['%c[channel1]%c %s', 'color: red', '', 'testing']); let customLoggerArgs: string[] = []; logger1.logFn = (...x: string[]) => { @@ -50,18 +50,20 @@ export default test.describe.parallel('debug', () => { logger1('second test'); expect(args).toEqual(['1']); expect(customLoggerArgs).toEqual([ - '%c[channel1]', + '%c[channel1]%c %s', 'color: red', + '', 'second test', ]); logger1.logFn = undefined; logger1('third test'); - expect(args).toEqual(['%c[channel1]', 'color: red', 'third test']); + expect(args).toEqual(['%c[channel1]%c %s', 'color: red', '', 'third test']); expect(customLoggerArgs).toEqual([ - '%c[channel1]', + '%c[channel1]%c %s', 'color: red', + '', 'second test', ]); }); @@ -84,11 +86,11 @@ export default test.describe.parallel('debug', () => { const oneabz = debug('channel1:a:b:z'); oneax('1ax'); - expect(args).toEqual(['%c[channel1:a:x]', 'color: red', '1ax']); + expect(args).toEqual(['%c[channel1:a:x]%c %s', 'color: red', '', '1ax']); onebx('1bx'); expect(args).toEqual( - (prevArgs = ['%c[channel1:b:x]', 'color: green', '1bx']), + (prevArgs = ['%c[channel1:b:x]%c %s', 'color: green', '', '1bx']), ); oneaz('1az'); @@ -100,11 +102,11 @@ export default test.describe.parallel('debug', () => { expect(args).toEqual(prevArgs); onecx('1cx'); - expect(args).toEqual(['%c[channel1:c:x]', 'color: red', '1cx']); + expect(args).toEqual(['%c[channel1:c:x]%c %s', 'color: red', '', '1cx']); oneabx('1abx'); expect(args).toEqual( - (prevArgs = ['%c[channel1:a:b:x]', 'color: green', '1abx']), + (prevArgs = ['%c[channel1:a:b:x]%c %s', 'color: green', '', '1abx']), ); oneabz('1abz'); @@ -125,17 +127,17 @@ export default test.describe.parallel('debug', () => { const three = debug('channel3'); onea('1a'); - expect(args).toEqual(['%c[channel1:a]', 'color: red', '1a']); + expect(args).toEqual(['%c[channel1:a]%c %s', 'color: red', '', '1a']); oneb('1b'); - expect(args).toEqual(['%c[channel1:b]', 'color: green', '1b']); + expect(args).toEqual(['%c[channel1:b]%c %s', 'color: green', '', '1b']); two('2'); - expect(args).toEqual(['%c[channel2]', 'color: blue', '2']); + expect(args).toEqual(['%c[channel2]%c %s', 'color: blue', '', '2']); three('3'); // same as 2, since 3 is not enabled - expect(args).toEqual(['%c[channel2]', 'color: blue', '2']); + expect(args).toEqual(['%c[channel2]%c %s', 'color: blue', '', '2']); }); test('channel negation working', () => { @@ -150,7 +152,9 @@ export default test.describe.parallel('debug', () => { const oneb = debug('channel1:b'); onea('1a'); - expect(args).toEqual((prevArgs = ['%c[channel1:a]', 'color: red', '1a'])); + expect(args).toEqual( + (prevArgs = ['%c[channel1:a]%c %s', 'color: red', '', '1a']), + ); oneb('1b'); // not logged diff --git a/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx b/source/src/components/HeadlessTable/HeadlessTableWithPinnedContainers.tsx index 7e6c8908a..bec46c421 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 new file mode 100644 index 000000000..b90b0f9b6 --- /dev/null +++ b/source/src/components/HeadlessTable/HorizontalLayoutTableRenderer.tsx @@ -0,0 +1,109 @@ +import { ThemeVars } from '../InfiniteTable/vars.css'; +import { HorizontalLayoutMatrixBrain } from '../VirtualBrain/HorizontalLayoutMatrixBrain'; +import { + columnOffsetAtIndex, + columnOffsetAtIndexWhileReordering, + currentTransformY, + ReactHeadlessTableRenderer, +} from './ReactHeadlessTableRenderer'; + +export class HorizontalLayoutTableRenderer extends ReactHeadlessTableRenderer { + protected brain: HorizontalLayoutMatrixBrain; + constructor(brain: HorizontalLayoutMatrixBrain, debugId?: string) { + super(brain, debugId); + this.brain = brain; + } + + protected getCellRealCoordinates(rowIndex: number, colIndex: number) { + // return { + // rowIndex, + // colIndex, + // }; + return this.brain.getHorizontalLayoutPositionFromMatrixCoordinates({ + rowIndex, + colIndex, + }); + } + + isCellRenderedAndMappedCorrectly(row: number, col: number) { + const rendered = this.mappedCells.isCellRendered(row, col); + + if (!rendered) { + return { + rendered, + mapped: false, + }; + } + + const cellAdditionalInfo = this.mappedCells.getCellAdditionalInfo(row, col); + + if (!cellAdditionalInfo) { + return { + rendered, + mapped: false, + }; + } + + const info = this.getCellRealCoordinates(row, col); + + const mapped = + info.colIndex === cellAdditionalInfo!.renderColIndex && + info.rowIndex === cellAdditionalInfo!.renderRowIndex; + + return { + rendered, + mapped, + }; + } + + setTransform = ( + 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, + ); + + const { y } = options; + const pageIndex = Math.floor( + horizontalLayoutCoords.rowIndex / this.brain.rowsPerPage, + ); + const pageWidth = ThemeVars.runtime.totalVisibleColumnsWidthVar; + const pageOffset = pageIndex ? `calc(${pageWidth} * ${pageIndex})` : '0px'; + + const columnOffsetX = `${columnOffsetAtIndex}-${horizontalLayoutCoords.colIndex}`; + const columnOffsetXWhileReordering = `${columnOffsetAtIndexWhileReordering}-${horizontalLayoutCoords.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})`; + const transformX = `var(${columnOffsetXWhileReordering}, ${xOffset})`; + const transformY = `var(${currentTransformY})`; + + const transformValue = `translate3d(${transformX}, ${transformY}, 0px)`; + + //@ts-ignore + if (element.__transformValue !== transformValue) { + //@ts-ignore + element.__transformValue = transformValue; + element.style.transform = transformValue; + } + }; +} diff --git a/source/src/components/HeadlessTable/MappedCells.ts b/source/src/components/HeadlessTable/MappedCells.ts index 1b8b6b94e..eb7bd150f 100644 --- a/source/src/components/HeadlessTable/MappedCells.ts +++ b/source/src/components/HeadlessTable/MappedCells.ts @@ -14,7 +14,7 @@ import { TableRenderRange } from '../VirtualBrain/MatrixBrain'; * This class has tests - see tests/mapped-cells.spec.ts */ -export class MappedCells extends Logger { +export class MappedCells extends Logger { /** * This is the mapping from element index to cell info. * The index in the array is the element index while the value at the position is an array where @@ -29,14 +29,21 @@ export class MappedCells extends Logger { */ private cellToElementIndex!: DeepMap; + private cellAdditionalInfo!: DeepMap; + /** * Keeps the JSX of rendered elements in memory, so we can possibly reuse it later. */ private renderedElements!: Renderable[]; - constructor() { + private withCellAdditionalInfo: boolean = false; + + constructor(opts?: { withCellAdditionalInfo: boolean }) { super(`MappedCells`); this.init(); + if (opts?.withCellAdditionalInfo) { + this.withCellAdditionalInfo = opts.withCellAdditionalInfo; + } // if (__DEV__) { // (globalThis as any).mappedCells = this; @@ -69,6 +76,7 @@ export class MappedCells extends Logger { init() { this.elementIndexToCell = []; this.cellToElementIndex = new DeepMap(); + this.cellAdditionalInfo = new DeepMap(); this.renderedElements = []; } @@ -79,6 +87,7 @@ export class MappedCells extends Logger { destroy() { this.elementIndexToCell = []; this.cellToElementIndex.clear(); + this.cellAdditionalInfo.clear(); this.renderedElements = []; } @@ -129,6 +138,13 @@ export class MappedCells extends Logger { return this.cellToElementIndex.has([rowIndex, columnIndex]); }; + getCellAdditionalInfo = ( + rowIndex: number, + columnIndex: number, + ): T_ADDITIONAL_CELL_INFO | undefined => { + return this.cellAdditionalInfo.get([rowIndex, columnIndex]); + }; + isElementRendered = (elementIndex: number): boolean => { return !!this.elementIndexToCell[elementIndex]; }; @@ -137,6 +153,12 @@ export class MappedCells extends Logger { return this.cellToElementIndex.getValuesStartingWith([rowIndex]); }; + getAdditionalInfoForRowIndex = ( + rowIndex: number, + ): T_ADDITIONAL_CELL_INFO[] => { + return this.cellAdditionalInfo.getValuesStartingWith([rowIndex]); + }; + getRenderedNodeAtElement = (elementIndex: number): Renderable | null => { return this.renderedElements[elementIndex] || null; }; @@ -172,7 +194,8 @@ export class MappedCells extends Logger { rowIndex: number, colIndex: number, elementIndex: number, - renderNode?: Renderable, + renderNode: Renderable | undefined, + cellAdditionalInfo?: T_ADDITIONAL_CELL_INFO, ) => { if (__DEV__) { this.debug( @@ -184,7 +207,11 @@ export class MappedCells extends Logger { const currentCell = this.elementIndexToCell[elementIndex]; if (currentCell) { - this.cellToElementIndex.delete([currentCell[0], currentCell[1]]); + const currentCellKey = [currentCell[0], currentCell[1]]; + this.cellToElementIndex.delete(currentCellKey); + if (this.withCellAdditionalInfo) { + this.cellAdditionalInfo.delete(currentCellKey); + } } if (renderNode) { this.renderedElements[elementIndex] = renderNode; @@ -192,6 +219,9 @@ export class MappedCells extends Logger { this.elementIndexToCell[elementIndex] = [rowIndex, colIndex]; this.cellToElementIndex.set(key, elementIndex); + if (this.withCellAdditionalInfo && cellAdditionalInfo !== undefined) { + this.cellAdditionalInfo.set(key, cellAdditionalInfo); + } }; discardCell = (rowIndex: number, colIndex: number) => { @@ -202,6 +232,9 @@ export class MappedCells extends Logger { this.renderedElements[elementIndex] = null; this.elementIndexToCell[elementIndex] = null; this.cellToElementIndex.delete(key); + if (this.withCellAdditionalInfo) { + this.cellAdditionalInfo.delete(key); + } } }; @@ -213,6 +246,9 @@ export class MappedCells extends Logger { this.renderedElements[elementIndex] = null; this.elementIndexToCell[elementIndex] = null; this.cellToElementIndex.delete(key); + if (this.withCellAdditionalInfo) { + this.cellAdditionalInfo.delete(key); + } return cell; } diff --git a/source/src/components/HeadlessTable/RawTable.tsx b/source/src/components/HeadlessTable/RawTable.tsx index 349c128b1..f58cb820d 100644 --- a/source/src/components/HeadlessTable/RawTable.tsx +++ b/source/src/components/HeadlessTable/RawTable.tsx @@ -4,8 +4,8 @@ import { useEffect, useLayoutEffect, useMemo } from 'react'; import { AvoidReactDiff } from '../RawList/AvoidReactDiff'; import { Renderable } from '../types/Renderable'; import { SubscriptionCallback } from '../types/SubscriptionCallback'; -import { buildSubscriptionCallback } from '../utils/buildSubscriptionCallback'; import { MatrixBrain } from '../VirtualBrain/MatrixBrain'; +import { createRenderer } from './createRenderer'; import { ReactHeadlessTableRenderer, @@ -23,20 +23,6 @@ export type RawTableProps = { onRenderUpdater?: SubscriptionCallback; }; -function createRenderer(brain: MatrixBrain) { - const renderer = new ReactHeadlessTableRenderer(brain); - const onRenderUpdater = buildSubscriptionCallback(); - - brain.onDestroy(() => { - renderer.destroy(); - onRenderUpdater.destroy(); - }); - - return { - renderer, - onRenderUpdater, - }; -} export function RawTableFn(props: RawTableProps) { const { brain, renderCell, renderDetailRow } = props; @@ -93,7 +79,7 @@ export function RawTableFn(props: RawTableProps) { }); return remove; - }, [renderCell, renderDetailRow]); + }, [renderCell, renderDetailRow, brain, onRenderUpdater]); return ; } diff --git a/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx b/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx index 3e1b0ed2b..cb3a52ff9 100644 --- a/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx +++ b/source/src/components/HeadlessTable/ReactHeadlessTableRenderer.tsx @@ -63,35 +63,42 @@ 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 type HorizontalLayoutColVisibilityOptions = { + horizontalLayoutPageIndex?: number; +}; + 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<{ + renderRowIndex: number; + renderColIndex: number; + }>; private mappedDetailRows: MappedVirtualRows; private items: Renderable[] = []; @@ -193,14 +200,22 @@ export class ReactHeadlessTableRenderer extends Logger { } }; - constructor(brain: MatrixBrain) { - super('ReactHeadlessTableRenderer'); + constructor(brain: MatrixBrain, debugId?: string) { + debugId = debugId || 'ReactHeadlessTableRenderer'; + super(debugId); this.brain = brain; - this.debugId = brain.name; + this.debugId = debugId; - this.mappedCells = new MappedCells(); + this.mappedCells = new MappedCells<{ + renderRowIndex: number; + renderColIndex: number; + }>({ + withCellAdditionalInfo: brain.isHorizontalLayoutBrain, + }); this.mappedDetailRows = new MappedVirtualRows(); + this.renderRange = this.renderRange.bind(this); + const removeOnScroll = brain.onScroll(this.adjustFixedElementsOnScroll); const removeOnSizeChange = brain.onAvailableSizeChange(() => { this.adjustFixedElementsOnScroll(); @@ -318,18 +333,31 @@ export class ReactHeadlessTableRenderer extends Logger { config: { scrollAdjustPosition?: ScrollAdjustPosition; offset?: number; - } = { offset: 0 }, + } & HorizontalLayoutColVisibilityOptions = { offset: 0 }, ): ScrollPosition | null => { if (this.destroyed) { return null; } const { brain } = this; const scrollPosition = brain.getScrollPosition(); + const horizLayoutOptions = + config.horizontalLayoutPageIndex != null + ? { horizontalLayoutPageIndex: config.horizontalLayoutPageIndex } + : undefined; let { scrollAdjustPosition, offset = 0 } = config; - if (this.isColumnFullyVisible(colIndex) && !scrollAdjustPosition) { + if ( + this.isColumnFullyVisible(colIndex, undefined, horizLayoutOptions) && + !scrollAdjustPosition + ) { return scrollPosition; } + if (horizLayoutOptions) { + colIndex = + brain.getInitialCols() * horizLayoutOptions.horizontalLayoutPageIndex + + colIndex; + } + const colOffset = brain.getItemOffsetFor(colIndex, 'horizontal'); const colWidth = brain.getItemSize(colIndex, 'horizontal'); @@ -431,6 +459,11 @@ export class ReactHeadlessTableRenderer extends Logger { if (!this.isRowRendered(rowIndex)) { return false; } + const pageIndex = this.brain.getPageIndexForRow(rowIndex); + rowIndex = + pageIndex && this.brain.rowsPerPage + ? rowIndex % this.brain.rowsPerPage + : rowIndex; const { brain } = this; @@ -476,28 +509,58 @@ export class ReactHeadlessTableRenderer extends Logger { }; public isRowRendered = (rowIndex: number) => { - const elements = this.mappedCells.getElementsForRowIndex(rowIndex); + if (!this.brain.isHorizontalLayoutBrain) { + const elements = this.mappedCells.getElementsForRowIndex(rowIndex); + + return elements.length > 0; + } + + const initialRowIndex = rowIndex; - return elements.length > 0; + rowIndex = this.brain.rowsPerPage + ? rowIndex % this.brain.rowsPerPage + : rowIndex; + + return ( + this.mappedCells + .getAdditionalInfoForRowIndex(rowIndex) + .filter((info) => info.renderRowIndex === initialRowIndex).length > 0 + ); }; public isCellVisible = (rowIndex: number, colIndex: number) => { return this.isRowVisible(rowIndex) && this.isColumnVisible(colIndex); }; - public isCellFullyVisible = (rowIndex: number, colIndex: number) => { - return this.isRowFullyVisible(rowIndex) && this.isColumnVisible(colIndex); + public isCellFullyVisible = ( + rowIndex: number, + colIndex: number, + opts?: HorizontalLayoutColVisibilityOptions, + ) => { + return ( + this.isRowFullyVisible(rowIndex) && + this.isColumnVisible(colIndex, undefined, opts) + ); }; - public isColumnFullyVisible = (colIndex: number, offsetMargin = 2) => { + public isColumnFullyVisible = ( + colIndex: number, + offsetMargin = 2, + opts?: HorizontalLayoutColVisibilityOptions, + ) => { return this.isColumnVisible( colIndex, this.brain.getColWidth(colIndex) - offsetMargin, + opts, ); }; - public isColumnVisible = (colIndex: number, offsetMargin = 10) => { - if (!this.isColumnRendered(colIndex)) { + public isColumnVisible = ( + colIndex: number, + offsetMargin = 10, + opts?: HorizontalLayoutColVisibilityOptions, + ) => { + if (!this.isColumnRendered(colIndex, opts)) { return false; } @@ -506,6 +569,11 @@ export class ReactHeadlessTableRenderer extends Logger { if (brain.isColFixed(colIndex)) { return true; } + if (opts && opts.horizontalLayoutPageIndex != null) { + colIndex = brain.getVirtualColIndex(colIndex, { + pageIndex: opts.horizontalLayoutPageIndex, + }); + } const { start: [_, startCol], @@ -547,16 +615,33 @@ export class ReactHeadlessTableRenderer extends Logger { return true; }; - public isCellRendered = (rowIndex: number, colIndex: number) => { - return this.isRowRendered(rowIndex) && this.isColumnRendered(colIndex); + public isCellRendered = ( + rowIndex: number, + colIndex: number, + opts?: HorizontalLayoutColVisibilityOptions, + ) => { + return ( + this.isRowRendered(rowIndex) && this.isColumnRendered(colIndex, opts) + ); }; - public isColumnRendered = (colIndex: number) => { + public isColumnRendered = ( + colIndex: number, + opts?: HorizontalLayoutColVisibilityOptions, + ) => { const { start: [startRow], } = this.brain.getRenderRange(); - return this.mappedCells.getRenderedNodeForCell(startRow, colIndex) !== null; + if (opts?.horizontalLayoutPageIndex != null) { + const colsCount = this.brain.getInitialCols(); + + colIndex = colsCount * opts.horizontalLayoutPageIndex + colIndex; + } + const nodeRendered = + this.mappedCells.getRenderedNodeForCell(startRow, colIndex) !== null; + + return nodeRendered; }; getExtraSpanCellsForRange = (range: TableRenderRange) => { @@ -570,7 +655,15 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - renderRange = ( + isCellRenderedAndMappedCorrectly(row: number, col: number) { + const rendered = this.mappedCells.isCellRendered(row, col); + return { + rendered, + mapped: rendered, + }; + } + + renderRange( range: TableRenderRange, { @@ -584,7 +677,7 @@ export class ReactHeadlessTableRenderer extends Logger { renderDetailRow?: TableRenderDetailRowFn; onRender: (items: Renderable[]) => void; }, - ): Renderable[] => { + ): Renderable[] { if (this.destroyed) { return []; } @@ -771,7 +864,8 @@ export class ReactHeadlessTableRenderer extends Logger { continue; } visitedCells.set(key, true); - const cellRendered = mappedCells.isCellRendered(row, col); + const { rendered: cellRendered, mapped: cellMappedCorrectly } = + this.isCellRenderedAndMappedCorrectly(row, col); // for cells that belong to the first row of the render range // or to the first column of the render range @@ -803,7 +897,7 @@ export class ReactHeadlessTableRenderer extends Logger { } } - if (cellRendered && !force) { + if (cellRendered && !force && cellMappedCorrectly) { continue; } @@ -854,8 +948,12 @@ export class ReactHeadlessTableRenderer extends Logger { }); extraCells.forEach(([rowIndex, colIndex]) => { - if (mappedCells.isCellRendered(rowIndex, colIndex)) { - if (force) { + const { rendered, mapped } = this.isCellRenderedAndMappedCorrectly( + rowIndex, + colIndex, + ); + if (rendered) { + if (force || !mapped) { const elementIndex = mappedCells.getElementIndexForCell( rowIndex, colIndex, @@ -903,7 +1001,7 @@ export class ReactHeadlessTableRenderer extends Logger { // } return result; - }; + } private renderElement(elementIndex: number) { const domRef = (node: HTMLElement | null) => { @@ -1106,7 +1204,7 @@ export class ReactHeadlessTableRenderer extends Logger { return arr; }; - private isCellFixed = ( + protected isCellFixed = ( rowIndex: number, colIndex: number, ): { row: FixedPosition; col: FixedPosition } => { @@ -1154,7 +1252,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 +1323,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 +1366,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,29 +1395,28 @@ export class ReactHeadlessTableRenderer extends Logger { return; } - // console.log('render row', rowIndex); + const cellAdditionalInfo = this.brain.isHorizontalLayoutBrain + ? { + renderRowIndex, + renderColIndex, + } + : undefined; this.mappedCells.renderCellAtElement( rowIndex, colIndex, elementIndex, renderedNode, + cellAdditionalInfo, ); - 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; } - private onMouseEnter = (rowIndex: number) => { + protected onMouseEnter = (rowIndex: number) => { this.currentHoveredRow = rowIndex; if (this.scrolling) { @@ -1329,7 +1437,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 +1460,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - private updateHoverClassNamesForRow = (rowIndex: number) => { + protected updateHoverClassNamesForRow = (rowIndex: number) => { if (this.scrolling) { return; } @@ -1384,7 +1492,7 @@ export class ReactHeadlessTableRenderer extends Logger { }); }; - private updateElementPosition = ( + protected updateElementPosition = ( elementIndex: number, options?: { hidden: boolean; rowspan: number; colspan: number }, ) => { @@ -1417,9 +1525,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/createRenderer.ts b/source/src/components/HeadlessTable/createRenderer.ts new file mode 100644 index 000000000..a15c55698 --- /dev/null +++ b/source/src/components/HeadlessTable/createRenderer.ts @@ -0,0 +1,34 @@ +import { Renderable } from '../types/Renderable'; +import { buildSubscriptionCallback } from '../utils/buildSubscriptionCallback'; +import { HorizontalLayoutMatrixBrain } from '../VirtualBrain/HorizontalLayoutMatrixBrain'; +import { MatrixBrain } from '../VirtualBrain/MatrixBrain'; +import { HorizontalLayoutTableRenderer } from './HorizontalLayoutTableRenderer'; +import { ReactHeadlessTableRenderer } from './ReactHeadlessTableRenderer'; + +export function createRenderer(brain: MatrixBrain) { + const renderer = !brain.isHorizontalLayoutBrain + ? new ReactHeadlessTableRenderer( + brain, + `ReactHeadlessTableRenderer:${brain.name}`, + ) + : new HorizontalLayoutTableRenderer( + brain as HorizontalLayoutMatrixBrain, + `HorizontalLayoutTableRenderer:${brain.name}`, + ); + + const onRenderUpdater = buildSubscriptionCallback(); + + brain.onDestroy(() => { + renderer.destroy(); + onRenderUpdater.destroy(); + }); + + if (__DEV__) { + (brain as any).renderer = renderer; + } + + return { + renderer, + onRenderUpdater, + }; +} diff --git a/source/src/components/HeadlessTable/index.tsx b/source/src/components/HeadlessTable/index.tsx index 71fb24cb1..0def12f64 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; @@ -202,23 +204,30 @@ export function HeadlessTable( const remove = setupResizeObserver(node, onResize, { debounce: 50 }); return remove; + }, [wrapRowsHorizontally, brain]); + + const updateDOMTransform = useCallback((scrollPos: ScrollPosition) => { + domRef.current!.style.transform = `translate3d(${-scrollPos.scrollLeft}px, ${-scrollPos.scrollTop}px, 0px)`; }, []); const onContainerScroll = useCallback( (scrollPos: ScrollPosition) => { - brain.setScrollPosition(scrollPos, (scrollPos) => { - domRef.current!.style.transform = `translate3d(${-scrollPos.scrollLeft}px, ${-scrollPos.scrollTop}px, 0px)`; - }); + brain.setScrollPosition(scrollPos, updateDOMTransform); }, - [brain], + [brain, updateDOMTransform], ); useEffect(() => { const removeOnRenderCount = brain.onRenderCountChange(() => { - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); }); - setTotalScrollSize(brain.getTotalSize()); + setTotalScrollSize(brain.getVirtualizedContentSize()); + + // useful when the brain is changed - when toggling the value of wrapRowsHorizontally + updateDOMTransform( + brain.getScrollPosition() || { scrollLeft: 0, scrollTop: 0 }, + ); return removeOnRenderCount; }, [brain]); @@ -242,13 +251,17 @@ export function HeadlessTable( brain={brain} cellHoverClassNames={cellHoverClassNames} /> - + {activeCellIndex != null ? ( + + ) : null} - + {activeRowIndex != null ? ( + + ) : null} ( sortInfo: DataSourceSingleSortInfo, @@ -1200,7 +1201,7 @@ class InfiniteTableApiImpl implements InfiniteTableApi { config: { scrollAdjustPosition?: ScrollAdjustPosition; offset?: number; - } = { offset: 0 }, + } & HorizontalLayoutColVisibilityOptions = { offset: 0 }, ) { const state = this.getState(); const computed = this.getComputed(); @@ -1255,6 +1256,15 @@ class InfiniteTableApiImpl implements InfiniteTableApi { colIndex = computedColumn.computedVisibleIndex; } + if (state.brain.isHorizontalLayoutBrain) { + config = config || {}; + + const pageIndex = state.brain.getPageIndexForRow(rowIndex); + ( + config as HorizontalLayoutColVisibilityOptions + ).horizontalLayoutPageIndex = pageIndex || 0; + } + const scrollPositionForCol = state.renderer.getScrollPositionForScrollColumnIntoView(colIndex, config); const scrollPositionForRow = diff --git a/source/src/components/InfiniteTable/components/ActiveCellIndicator.tsx b/source/src/components/InfiniteTable/components/ActiveCellIndicator.tsx index 9fba8feec..0cba4b401 100644 --- a/source/src/components/InfiniteTable/components/ActiveCellIndicator.tsx +++ b/source/src/components/InfiniteTable/components/ActiveCellIndicator.tsx @@ -39,7 +39,12 @@ const reposition = ( return; } - const rowIndex = activeCellIndex[0]; + let [rowIndex] = activeCellIndex; + + if (brain.isHorizontalLayoutBrain && brain.rowsPerPage) { + rowIndex = rowIndex % brain.rowsPerPage; + } + const activeCellRowHeight: number = typeof rowHeight === 'function' ? rowHeight(rowIndex) @@ -65,7 +70,7 @@ const ActiveCellIndicatorFn = (props: ActiveCellIndicatorProps) => { const active = props.activeCellIndex != null && - brain.getRowCount() > props.activeCellIndex[0]; + brain.getInitialRows() > props.activeCellIndex[0]; useLayoutEffect(() => { reposition(brain, props.activeCellIndex, props.rowHeight, domRef); diff --git a/source/src/components/InfiniteTable/components/ActiveRowIndicator.tsx b/source/src/components/InfiniteTable/components/ActiveRowIndicator.tsx index 9ca53fb9a..cc068322c 100644 --- a/source/src/components/InfiniteTable/components/ActiveRowIndicator.tsx +++ b/source/src/components/InfiniteTable/components/ActiveRowIndicator.tsx @@ -26,7 +26,7 @@ const ActiveStyle: CSSProperties = { InternalVars.activeCellOffsetY } + var(${stripVar(InternalVars.scrollTopForActiveRow)}, 0px)), 0px)`, height: InternalVars.activeCellRowHeight, - maxWidth: ThemeVars.runtime.totalVisibleColumnsWidth, + maxWidth: ThemeVars.runtime.totalVisibleColumnsWidthVar, }; const ActiveRowIndicatorFn = (props: ActiveRowIndicatorProps) => { diff --git a/source/src/components/InfiniteTable/components/HScrollSyncContent.tsx b/source/src/components/InfiniteTable/components/HScrollSyncContent.tsx index eb3f178aa..7e75e1a78 100644 --- a/source/src/components/InfiniteTable/components/HScrollSyncContent.tsx +++ b/source/src/components/InfiniteTable/components/HScrollSyncContent.tsx @@ -28,17 +28,17 @@ export function HScrollSyncContent( const style = { ...domProps.style }; if (width === 'column') { - style.width = ThemeVars.runtime.totalVisibleColumnsWidth; + style.width = ThemeVars.runtime.totalVisibleColumnsWidthValue; } else if (width === 'viewport') { style.width = ThemeVars.runtime.bodyWidth; } if (minWidth === 'column') { - style.minWidth = ThemeVars.runtime.totalVisibleColumnsWidth; + style.minWidth = ThemeVars.runtime.totalVisibleColumnsWidthValue; } else if (minWidth === 'viewport') { style.minWidth = ThemeVars.runtime.bodyWidth; } if (maxWidth === 'column') { - style.maxWidth = ThemeVars.runtime.totalVisibleColumnsWidth; + style.maxWidth = ThemeVars.runtime.totalVisibleColumnsWidthValue; } else if (maxWidth === 'viewport') { style.maxWidth = ThemeVars.runtime.bodyWidth; } diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableColumnHeaderFilterContext.ts b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableColumnHeaderFilterContext.ts index 3477318c0..e5ab6b00f 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableColumnHeaderFilterContext.ts +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableColumnHeaderFilterContext.ts @@ -8,6 +8,7 @@ import { Renderable } from '../../../types/Renderable'; import { rootClassName } from '../../internalProps'; export type InfiniteTableColumnHeaderFilterProps = { + horizontalLayoutPageIndex: null | number; filterEditor: () => JSX.Element | null; filterOperatorSwitch: () => JSX.Element | null; filterTypes: DataSourcePropFilterTypes; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx index 4f403a5ce..83663cfdd 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx @@ -45,20 +45,25 @@ function InfiniteTableHeaderFn( const { computedColumnsMap } = computed; + const domRef = useRef(null); + + const updateDOMTransform = useCallback((scrollPosition: ScrollPosition) => { + if (domRef.current) { + domRef.current.style.transform = `translate3d(-${scrollPosition.scrollLeft}px, 0px, 0px)`; + } + }, []); + useEffect(() => { - const onScroll = (scrollPosition: ScrollPosition) => { - if (domRef.current) { - domRef.current.style.transform = `translate3d(-${scrollPosition.scrollLeft}px, 0px, 0px)`; - } - }; + const removeOnScroll = brain.onScroll(updateDOMTransform); - const removeOnScroll = brain.onScroll(onScroll); + // useful when the brain is changed - when toggling the value of wrapRowsHorizontally + updateDOMTransform( + brain.getScrollPosition() || { scrollLeft: 0, scrollTop: 0 }, + ); return removeOnScroll; }, [brain]); - const domRef = useRef(null); - const headerCls = HeaderClsRecipe({ overflow: false, }); @@ -91,6 +96,9 @@ function InfiniteTableHeaderFn( if (!column || hidden) { return null; } + const horizontalLayoutPageIndex = headerBrain.isHorizontalLayoutBrain + ? headerBrain.getPageIndexForRow(rowIndex) + : null; const colGroupItem = columnAndGroupTreeInfo ? columnAndGroupTreeInfo.pathsToCells.get([rowIndex, colIndex]) : null; @@ -111,6 +119,7 @@ function InfiniteTableHeaderFn( return visible ? ( ( domRef={domRef} column={column} + horizontalLayoutPageIndex={horizontalLayoutPageIndex} headerOptions={headerOptions} width={widthWithColspan} height={heightWithRowspan} @@ -144,6 +154,7 @@ function InfiniteTableHeaderFn( columnAndGroupTreeInfo, columnGroupsMaxDepth, showColumnFilters, + headerBrain, ], ); diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderCell.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderCell.tsx index 0e8ee8de3..c5aaf662d 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderCell.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderCell.tsx @@ -261,6 +261,7 @@ export function InfiniteTableHeaderCell( const menuIcon = ; const initialRenderParam: InfiniteTableColumnHeaderParam = { + horizontalLayoutPageIndex: props.horizontalLayoutPageIndex, dragging, domRef: ref, insideColumnMenu: false, @@ -561,7 +562,9 @@ export function InfiniteTableHeaderCell( filterType?.components?.FilterOperatorSwitch || column.components?.FilterOperatorSwitch; - const resizeHandle = useColumnResizeHandle(column); + const resizeHandle = useColumnResizeHandle(column, { + horizontalLayoutPageIndex: props.horizontalLayoutPageIndex, + }); const zIndex = `var(${columnZIndexAtIndex}-${column.computedVisibleIndex})`; style.zIndex = zIndex; @@ -585,6 +588,7 @@ export function InfiniteTableHeaderCell( domRef={ref} + horizontalLayoutPageIndex={props.horizontalLayoutPageIndex} cellType="header" column={column} {...dataAttrs} @@ -631,6 +635,7 @@ export function InfiniteTableHeaderCell( {showColumnFilters ? ( column.computedFilterable ? ( ( const { columnGroup, height, columns, bodyBrain, columnGroupsMaxDepth } = props; - let { header } = columnGroup; + let { header, style: userStyle } = columnGroup; if (header instanceof Function) { header = header({ columnGroup, + horizontalLayoutPageIndex: props.horizontalLayoutPageIndex, }); } @@ -57,12 +58,24 @@ export function InfiniteTableHeaderGroup( firstColumn.computedVisibleIndex }) + ${columnGroupsMaxDepth - columnGroup.depth})`; + let style = + typeof userStyle === 'function' + ? userStyle({ + columnGroup, + horizontalLayoutPageIndex: props.horizontalLayoutPageIndex, + }) + : userStyle; + style = style && typeof style === 'object' ? style : {}; + + style.width = width; + style.height = height; + return (
= { }; export type InfiniteTableHeaderGroupProps = { + horizontalLayoutPageIndex: number | null; bodyBrain: MatrixBrain; columns: InfiniteTableComputedColumn[]; columnGroup: InfiniteTableComputedColumnGroup; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderWrapper.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderWrapper.tsx index cd2abc06b..e61fdf9e3 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderWrapper.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeaderWrapper.tsx @@ -14,6 +14,7 @@ export type TableHeaderWrapperProps = { headerBrain: MatrixBrain; bodyBrain: MatrixBrain; scrollbars: Scrollbars; + wrapRowsHorizontally: boolean; }; export function TableHeaderWrapper(props: TableHeaderWrapperProps) { const { headerBrain, bodyBrain, scrollbars } = props; @@ -175,7 +176,7 @@ export function TableHeaderWrapper(props: TableHeaderWrapperProps) {
{header} diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/GroupResizeHandle.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/GroupResizeHandle.tsx index 378c1d590..349032680 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/GroupResizeHandle.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/GroupResizeHandle.tsx @@ -93,7 +93,7 @@ function GroupResizeHandleFn(props: GroupResizeHandleProps) { height: currentSize.height, }; - props.brain.setAvailableSize(newSize); + props.brain.update(newSize); initialMove = false; } diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/index.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/index.tsx index 6e846ea66..4e72eb2a9 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/index.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/ResizeHandle/index.tsx @@ -10,8 +10,10 @@ import { ResizeHandleDraggerClsRecipe, ResizeHandleRecipeCls, } from './ResizeHandle.css'; +import { useInfiniteTable } from '../../../hooks/useInfiniteTable'; type ResizeHandleProps = { + horizontalLayoutPageIndex: number | null; columnIndex: number; columns: InfiniteTableComputedColumn[]; @@ -41,12 +43,16 @@ const { rootClassName } = internalProps; export const InfiniteTableHeaderCellResizeHandleCls = `${rootClassName}HeaderCell_ResizeHandle`; function ResizeHandleFn(props: ResizeHandleProps) { + const { + state: { brain, headerBrain }, + } = useInfiniteTable(); const domRef = useRef(null); const [constrained, setConstrained] = useState(false); const constrainedRef = useRef(constrained); constrainedRef.current = constrained; const col = props.columns[props.columnIndex]; + const horizontalLayoutPageIndex = props.horizontalLayoutPageIndex; if (!col) { return null; @@ -55,6 +61,7 @@ function ResizeHandleFn(props: ResizeHandleProps) { const computedFirstInCategory = col.computedFirstInCategory; const computedLastInCategory = col.computedLastInCategory; + let restoreRenderRange: () => void = () => {}; const onPointerDown = (e: PointerEvent) => { e.stopPropagation(); @@ -63,6 +70,21 @@ function ResizeHandleFn(props: ResizeHandleProps) { const initialX = e.clientX; const target = e.target as HTMLElement; + if (brain.isHorizontalLayoutBrain) { + const restoreBodyRange = brain.extendRenderRange({ + left: true, + right: true, + }); + const restoreHeaderRange = headerBrain.extendRenderRange({ + left: true, + right: true, + }); + restoreRenderRange = () => { + restoreBodyRange(); + restoreHeaderRange(); + }; + } + target.setPointerCapture(pointerId); const resizer = getColumnResizer(props.columnIndex, { @@ -71,7 +93,13 @@ function ResizeHandleFn(props: ResizeHandleProps) { domRef, }); - const resizeDiff = (diff: number) => { + const resizeDiff = (diff: number, { done }: { done?: boolean } = {}) => { + if (horizontalLayoutPageIndex) { + diff = diff / (horizontalLayoutPageIndex + 1); + if (done) { + diff = Math.round(diff); + } + } if (computedPinned === 'end') { diff *= -1; } @@ -102,7 +130,9 @@ function ResizeHandleFn(props: ResizeHandleProps) { target.removeEventListener('pointerup', onPointerUp); const diff = Math.round(e.clientX - initialX); - const adjustedDiff = resizeDiff(diff); + const adjustedDiff = resizeDiff(diff, { done: true }); + + restoreRenderRange(); props.onResize({ diff: adjustedDiff, shareSpaceOnResize }); }; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/getColumnLabel.ts b/source/src/components/InfiniteTable/components/InfiniteTableHeader/getColumnLabel.ts index e1f524779..d6ca3fe05 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/getColumnLabel.ts +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/getColumnLabel.ts @@ -40,6 +40,7 @@ export function getColumnLabel( })!; label = col.header({ + horizontalLayoutPageIndex: null, column: col, columnApi, insideColumnMenu: true, diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/useColumnResizeHandle.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/useColumnResizeHandle.tsx index 87cdda13d..0d98fa368 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/useColumnResizeHandle.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/useColumnResizeHandle.tsx @@ -10,6 +10,9 @@ import { ResizeHandle } from './ResizeHandle'; export function useColumnResizeHandle( column: InfiniteTableComputedColumn, + opts: { + horizontalLayoutPageIndex: number | null; + }, ) { const { computed: { computedVisibleColumns }, @@ -135,6 +138,7 @@ export function useColumnResizeHandle( const resizeHandle = column.computedResizable ? ( ( cellType, cssPosition: _cssPosition, renderChildren, + horizontalLayoutPageIndex, ...domProps } = props; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts index de99c2151..4e27a7df7 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableCellTypes.ts @@ -23,6 +23,8 @@ export type InfiniteTableBaseCellProps = { column: InfiniteTableComputedColumn; align?: InfiniteTableColumnAlignValues; + horizontalLayoutPageIndex: number | null; + rowId?: any; renderChildren: () => Renderable; @@ -65,6 +67,8 @@ export interface InfiniteTableColumnCellProps groupRenderStrategy: InfiniteTablePropGroupRenderStrategy; getData: () => InfiniteTableRowInfo[]; toggleGroupRow: InfiniteTableToggleGroupRowFn; + rowIndexInHorizontalLayoutPage: number | null; + horizontalLayoutPageIndex: 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 ca4e39bf4..c8ce5c664 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, + rowIndexInHorizontalLayoutPage, + horizontalLayoutPageIndex, getData, cellStyle, cellClassName, @@ -160,11 +162,22 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { fieldsToColumn, - domRef, + domRef: initialDomRef, hidden, showZebraRows, } = props; + const htmlElementRef = React.useRef(null); + const domRef = useCallback( + (node: HTMLElement | null) => { + htmlElementRef.current = node; + if (initialDomRef) { + initialDomRef(node); + } + }, + [initialDomRef], + ); + if (!column) { return
no column
; } @@ -201,8 +214,9 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { const rowDisabled = rowInfo.rowDisabled; const visibleColumnsIds = computed.computedVisibleColumns.map((x) => x.id); - const colRenderingParams = getColumnRenderingParams({ + horizontalLayoutPageIndex, + rowIndexInHorizontalLayoutPage, column, rowInfo, rowDetailState: rowDetailState, @@ -268,6 +282,7 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { const cellSelected = renderParam.cellSelected; renderParam.domRef = domRef; + renderParam.htmlElementRef = htmlElementRef; renderParam.selectCell = useCallback(renderParam.selectCell, [rowInfo]); renderParam.deselectCell = useCallback(renderParam.deselectCell, [rowInfo]); @@ -547,7 +562,9 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { ); const odd = - (rowInfo.indexInAll != null ? rowInfo.indexInAll : rowIndex) % 2 === 1; + rowIndexInHorizontalLayoutPage != null + ? rowIndexInHorizontalLayoutPage % 2 === 1 + : (rowInfo.indexInAll != null ? rowInfo.indexInAll : rowIndex) % 2 === 1; const zebra = showZebraRows ? (odd ? 'odd' : 'even') : false; @@ -607,6 +624,7 @@ function InfiniteTableColumnCellFn(props: InfiniteTableColumnCellProps) { column, width, rowId: rowInfo.id, + horizontalLayoutPageIndex, style: memoizedStyle, onMouseLeave, diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/columnRendering.tsx b/source/src/components/InfiniteTable/components/InfiniteTableRow/columnRendering.tsx index 575e6e01e..3f621507a 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/columnRendering.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/columnRendering.tsx @@ -156,6 +156,8 @@ export function getColumnValueToEdit(options: { export function getColumnRenderingParams(options: { column: InfiniteTableComputedColumn; + rowIndexInHorizontalLayoutPage: number | null; + horizontalLayoutPageIndex: number | null; rowInfo: InfiniteTableRowInfo; rowDetailState: 'expanded' | 'collapsed' | false; visibleColumnsIds: string[]; @@ -187,6 +189,8 @@ export function getColumnRenderingParams(options: { }); const stylingParam = { + rowIndexInHorizontalLayoutPage: options.rowIndexInHorizontalLayoutPage, + horizontalLayoutPageIndex: options.horizontalLayoutPageIndex, column: options.column, inEdit, rowHasSelectedCells: false, @@ -244,6 +248,8 @@ export function getColumnRenderingParams(options: { } export function getColumnRenderParam(options: { + rowIndexInHorizontalLayoutPage: number | null; + horizontalLayoutPageIndex: number | null; column: InfiniteTableComputedColumn; align: InfiniteTableColumnAlignValues; verticalAlign: InfiniteTableColumnVerticalAlignValues; @@ -287,7 +293,10 @@ export function getColumnRenderParam(options: { const cellSelected = cellSelection?.isCellSelected(rowInfo.id, column.id) ?? false; - const renderParam: Omit, 'domRef'> = { + const renderParam: Omit< + InfiniteTableColumnCellContextType, + 'domRef' | 'htmlElementRef' + > = { column, columnsMap, fieldsToColumn, @@ -296,6 +305,8 @@ export function getColumnRenderParam(options: { cellSelected, rowHasSelectedCells: false, + horizontalLayoutPageIndex: options.horizontalLayoutPageIndex, + rowIndexInHorizontalLayoutPage: options.rowIndexInHorizontalLayoutPage, ...formattedValueContext, editError: diff --git a/source/src/components/InfiniteTable/eventHandlers/keyboardNavigation.ts b/source/src/components/InfiniteTable/eventHandlers/keyboardNavigation.ts index a41dc31fd..761e18110 100644 --- a/source/src/components/InfiniteTable/eventHandlers/keyboardNavigation.ts +++ b/source/src/components/InfiniteTable/eventHandlers/keyboardNavigation.ts @@ -195,7 +195,16 @@ export function handleCellNavigation( if (rowIndex !== minRow) { colIndex = maxCol; } - KeyToFunction.ArrowUp(); + if (brain.isHorizontalLayoutBrain) { + const rowsPerPage = brain.rowsPerPage; + if (rowIndex - rowsPerPage >= minRow) { + rowIndex = rowIndex - rowsPerPage; + } else { + KeyToFunction.ArrowUp(); + } + } else { + KeyToFunction.ArrowUp(); + } } else { colIndex = clamp(colIndex - 1, minCol, maxCol); } @@ -205,7 +214,16 @@ export function handleCellNavigation( if (rowIndex !== maxRow) { colIndex = minCol; } - KeyToFunction.ArrowDown(); + if (brain.isHorizontalLayoutBrain) { + const rowsPerPage = brain.rowsPerPage; + if (rowIndex + rowsPerPage <= maxRow) { + rowIndex = rowIndex + rowsPerPage; + } else { + KeyToFunction.ArrowDown(); + } + } else { + KeyToFunction.ArrowDown(); + } } else { colIndex = clamp(colIndex + 1, minCol, maxCol); } diff --git a/source/src/components/InfiniteTable/hooks/useCellRendering.tsx b/source/src/components/InfiniteTable/hooks/useCellRendering.tsx index 662e9572c..5f474bdde 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,22 @@ export function useCellRendering( : 'collapsed'; } + const rowIndexInHorizontalLayoutPage = wrapRowsHorizontally + ? brain.getRowIndexInPage(rowIndex) + : null; + + const horizontalLayoutPageIndex = wrapRowsHorizontally + ? brain.getPageIndexForRow(rowIndex) + : null; + const cellProps: InfiniteTableColumnCellProps = { getData, virtualized: true, showZebraRows, groupRenderStrategy, + rowIndexInHorizontalLayoutPage, + horizontalLayoutPageIndex, + rowIndex, rowInfo, hidden, @@ -269,8 +281,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 36305d7df..27672ffab 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/hooks/useDOMProps.ts b/source/src/components/InfiniteTable/hooks/useDOMProps.ts index e62be008e..8fdcf4814 100644 --- a/source/src/components/InfiniteTable/hooks/useDOMProps.ts +++ b/source/src/components/InfiniteTable/hooks/useDOMProps.ts @@ -27,8 +27,12 @@ const publicRuntimeVars: Record< } > = { bodyWidth: { name: stripVar(ThemeVars.runtime.bodyWidth), value: '' }, - totalVisibleColumnsWidth: { - name: stripVar(ThemeVars.runtime.totalVisibleColumnsWidth), + totalVisibleColumnsWidthValue: { + name: stripVar(ThemeVars.runtime.totalVisibleColumnsWidthValue), + value: '', + }, + totalVisibleColumnsWidthVar: { + name: stripVar(ThemeVars.runtime.totalVisibleColumnsWidthVar), value: '', }, visibleColumnsCount: { @@ -183,12 +187,21 @@ export function useDOMProps( ] = `calc(${InternalVars.bodyWidth} - ${InternalVars.scrollbarWidthVertical})`; //@ts-ignore - cssVars[publicRuntimeVars.totalVisibleColumnsWidth.name] = `${ + cssVars[publicRuntimeVars.totalVisibleColumnsWidthValue.name] = `${ computedPinnedStartColumnsWidth + computedPinnedEndColumnsWidth + computedUnpinnedColumnsWidth }px`; + //@ts-ignore + cssVars[ + publicRuntimeVars.totalVisibleColumnsWidthVar.name + ] = `calc(${computedVisibleColumns + .map((_col, index) => { + return `var(${columnWidthAtIndex}-${index})`; + }) + .join(' + ')})`; + //@ts-ignore cssVars[publicRuntimeVars.visibleColumnsCount.name] = computedVisibleColumns.length; @@ -198,10 +211,20 @@ export function useDOMProps( cssVars[activeCellColWidth] = `var(${getCSSVarNameForColWidth( activeCellIndex[1], )})`; - //@ts-ignore - cssVars[activeCellColOffset] = `var(${getCSSVarNameForColOffset( + const defaultActiveCellColOffset = `var(${getCSSVarNameForColOffset( activeCellIndex[1], )})`; + if (state.brain.isHorizontalLayoutBrain) { + const pageIndex = state.brain.getPageIndexForRow(activeCellIndex[0]); + + //@ts-ignore + cssVars[activeCellColOffset] = pageIndex + ? `calc( ${ThemeVars.runtime.totalVisibleColumnsWidthVar} * ${pageIndex} + ${defaultActiveCellColOffset})` + : defaultActiveCellColOffset; + } else { + //@ts-ignore + cssVars[activeCellColOffset] = defaultActiveCellColOffset; + } } //@ts-ignore diff --git a/source/src/components/InfiniteTable/hooks/useToggleWrapRowsHorizontally.ts b/source/src/components/InfiniteTable/hooks/useToggleWrapRowsHorizontally.ts new file mode 100644 index 000000000..778785493 --- /dev/null +++ b/source/src/components/InfiniteTable/hooks/useToggleWrapRowsHorizontally.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { usePrevious } from '../../hooks/usePrevious'; +import { DEBUG_NAME } from '../InfiniteDebugName'; +import { createBrains } from '../state/getInitialState'; +import { useInfiniteTable } from './useInfiniteTable'; + +export function useToggleWrapRowsHorizontally() { + const { state, getState, actions } = useInfiniteTable(); + + const { wrapRowsHorizontally } = state; + const oldWrapRowsHorizontally = usePrevious(wrapRowsHorizontally); + + useEffect(() => { + if (oldWrapRowsHorizontally !== wrapRowsHorizontally) { + const { brain, headerBrain, renderer, onRenderUpdater } = getState(); + + brain.destroy(); + headerBrain.destroy(); + renderer.destroy(); + onRenderUpdater.destroy(); + + const newBrains = createBrains(DEBUG_NAME, !!wrapRowsHorizontally); + + actions.brain = newBrains.brain; + actions.headerBrain = newBrains.headerBrain; + + actions.renderer = newBrains.renderer; + actions.onRenderUpdater = newBrains.onRenderUpdater; + } + }, [oldWrapRowsHorizontally, wrapRowsHorizontally]); +} diff --git a/source/src/components/InfiniteTable/index.tsx b/source/src/components/InfiniteTable/index.tsx index 9d4640424..26fa77e53 100644 --- a/source/src/components/InfiniteTable/index.tsx +++ b/source/src/components/InfiniteTable/index.tsx @@ -89,6 +89,9 @@ import { HScrollSyncContent } from './components/HScrollSyncContent'; import { useGridScroll } from './hooks/useGridScroll'; import { useVisibleColumnSizes } from './hooks/useVisibleColumnSizes'; +import { DEBUG_NAME } from './InfiniteDebugName'; +import { useToggleWrapRowsHorizontally } from './hooks/useToggleWrapRowsHorizontally'; + export const InfiniteTableClassName = internalProps.rootClassName; const HOVERED_CLASS_NAMES = [RowHoverCls, 'InfiniteColumnCell--hovered']; @@ -113,18 +116,19 @@ const { ManagedComponentContextProvider: InfiniteTableRoot } = mappedCallbacks: getMappedCallbacks(), // @ts-ignore getParentState: () => useDataSourceState(), - debugName: 'InfiniteTable', + debugName: DEBUG_NAME, }); function InfiniteTableHeader() { const context = useInfiniteTable(); const { state: componentState, getComputed } = context; - const { header, brain, headerBrain } = componentState; + const { header, brain, headerBrain, wrapRowsHorizontally } = componentState; const { scrollbars } = getComputed(); return header ? ( () { activeCellIndex, rowDetailRenderer, showHoverRows, + wrapRowsHorizontally, domProps, } = componentState; @@ -246,6 +251,8 @@ function InfiniteTableBody() { const { autoFocus, tabIndex } = domProps ?? {}; + useToggleWrapRowsHorizontally(); + return ( () { } 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 13a8933f7..55c230c87 100644 --- a/source/src/components/InfiniteTable/state/getInitialState.ts +++ b/source/src/components/InfiniteTable/state/getInitialState.ts @@ -11,10 +11,9 @@ import { RowDetailCacheEntry, RowDetailCacheKey, } from '../../DataSource/state/getInitialState'; -import { ReactHeadlessTableRenderer } from '../../HeadlessTable/ReactHeadlessTableRenderer'; + import { ForwardPropsToStateFnResult } from '../../hooks/useComponentState'; import { CellPositionByIndex } from '../../types/CellPositionByIndex'; -import { Renderable } from '../../types/Renderable'; import { buildSubscriptionCallback } from '../../utils/buildSubscriptionCallback'; import { MatrixBrain } from '../../VirtualBrain/MatrixBrain'; import { ScrollListener } from '../../VirtualBrain/ScrollListener'; @@ -42,24 +41,11 @@ import { import { computeColumnGroupsDepths } from './computeColumnGroupsDepths'; import { getRowDetailRendererFromComponent } from './rowDetailRendererFromComponent'; +import { HorizontalLayoutMatrixBrain } from '../../VirtualBrain/HorizontalLayoutMatrixBrain'; +import { createRenderer } from '../../HeadlessTable/createRenderer'; const EMPTY_OBJECT = {}; -function createRenderer(brain: MatrixBrain) { - const renderer = new ReactHeadlessTableRenderer(brain); - 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}"` : '' @@ -68,28 +54,27 @@ export function getCellSelector(cellPosition?: CellPositionByIndex) { return selector; } -/** - * The computed state is independent from props and cannot - * be affected by props. - */ -export function initSetupState({ - debugId, -}: { - debugId?: string; -}): InfiniteTableSetupState { - const columnsGeneratedForGrouping: InfiniteTablePropColumns = {}; - +export function createBrains(debugId: string, wrapRowsHorizontally: boolean) { /** * This is the main virtualization brain that powers the table */ - const brain = new MatrixBrain(debugId); + const brain = !wrapRowsHorizontally + ? new MatrixBrain(debugId) + : new HorizontalLayoutMatrixBrain(debugId, { + isHeader: false, + }); /** * The brain that virtualises the header is different from the main brain * because obviously the header will have different rowspans/colspans * (which are due to column groups) than the main grid viewport */ - const headerBrain = new MatrixBrain('header'); + const headerBrain = !wrapRowsHorizontally + ? new MatrixBrain('header') + : new HorizontalLayoutMatrixBrain('header', { + isHeader: true, + masterBrain: brain as HorizontalLayoutMatrixBrain, + }); // however, we sync the headerBrain with the main brain // on horizontal scrolling @@ -109,12 +94,29 @@ export function initSetupState({ // and on width changes brain.onAvailableSizeChange((size) => { - headerBrain.setAvailableSize({ width: size.width }); + headerBrain.update({ width: size.width }); }); - if (__DEV__) { - (globalThis as any).renderer = renderer; - } + return { brain, headerBrain, renderer, onRenderUpdater }; +} + +/** + * The computed state is independent from props and cannot + * be affected by props. + */ +export function initSetupState({ + debugId, + wrapRowsHorizontally, +}: { + debugId: string; + wrapRowsHorizontally?: boolean; +}): InfiniteTableSetupState { + const columnsGeneratedForGrouping: InfiniteTablePropColumns = {}; + + const { brain, headerBrain, renderer, onRenderUpdater } = createBrains( + debugId, + !!wrapRowsHorizontally, + ); const domRef = createRef(); @@ -262,6 +264,8 @@ export const forwardProps = ( onScrollbarsChange: 1, autoSizeColumnsKey: 1, + wrapRowsHorizontally: 1, + columnDefaultFlex: 1, draggableColumnsRestrictTo: (draggableColumnsRestrictTo) => diff --git a/source/src/components/InfiniteTable/types/InfiniteTableColumn.ts b/source/src/components/InfiniteTable/types/InfiniteTableColumn.ts index 7694fe738..c937fe626 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableColumn.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableColumn.ts @@ -55,6 +55,7 @@ export type InfiniteTableColumnHeaderParam< columnSortInfo: DataSourceSingleSortInfo | null; columnFilterValue: DataSourceFilterValueItem | null; selectionMode: DataSourcePropSelectionMode; + horizontalLayoutPageIndex: null | number; allRowsSelected: boolean; someRowsSelected: boolean; filtered: boolean; @@ -91,6 +92,10 @@ export type InfiniteTableColumnRenderParamBase< COL_TYPE = InfiniteTableComputedColumn, > = { domRef: InfiniteTableCellProps['domRef']; + htmlElementRef: React.MutableRefObject; + + rowIndexInHorizontalLayoutPage: null | number; + horizontalLayoutPageIndex: null | number; // TODO type this to be the type of DATA_TYPE[column.field] if possible value: string | number | Renderable; @@ -310,6 +315,8 @@ export type InfiniteTableColumnWithRenderDescriptor = RequireAtLeastOne< export type InfiniteTableColumnStylingFnParams = { value: Renderable; column: InfiniteTableComputedColumn; + rowIndexInHorizontalLayoutPage: null | number; + horizontalLayoutPageIndex: null | number; inEdit: boolean; rowHasSelectedCells: boolean; editError: InfiniteTableColumnRenderParamBase['editError']; diff --git a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts index 0298a6e1a..37159cefc 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts @@ -439,14 +439,20 @@ export type InfiniteTablePropCollapsedColumnGroups = Map; export type InfiniteTableColumnGroupHeaderRenderParams = { columnGroup: InfiniteTableComputedColumnGroup; + horizontalLayoutPageIndex: number | null; }; export type InfiniteTableColumnGroupHeaderRenderFunction = ( params: InfiniteTableColumnGroupHeaderRenderParams, ) => Renderable; +export type InfiniteTableColumnGroupStyleFunction = ( + params: InfiniteTableColumnGroupHeaderRenderParams, +) => React.CSSProperties; + export type InfiniteTableColumnGroup = { columnGroup?: string; header?: Renderable | InfiniteTableColumnGroupHeaderRenderFunction; + style?: React.CSSProperties | InfiniteTableColumnGroupStyleFunction; }; export type InfiniteTableComputedColumnGroup = InfiniteTableColumnGroup & { id: string; @@ -597,6 +603,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 83150babf..6992eed1f 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableState.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableState.ts @@ -58,11 +58,14 @@ export type ContextMenuLocationWithEvent = Partial & { }; export interface InfiniteTableSetupState { + brain: MatrixBrain; + headerBrain: MatrixBrain; renderer: ReactHeadlessTableRenderer; + onRenderUpdater: SubscriptionCallback; + lastRowToExpandRef: MutableRefObject; lastRowToCollapseRef: MutableRefObject; getDOMNodeForCell: (cellPos: CellPositionByIndex) => HTMLElement | null; - onRenderUpdater: SubscriptionCallback; propsCache: Map, WeakMap>; columnsWhenInlineGroupRenderStrategy?: Record>; domRef: MutableRefObject; @@ -104,8 +107,7 @@ export interface InfiniteTableSetupState { keyDown: SubscriptionCallback; columnsWhenGrouping?: InfiniteTablePropColumns; bodySize: Size; - brain: MatrixBrain; - headerBrain: MatrixBrain; + focused: boolean; ready: boolean; columnReorderDragColumnId: false | string; @@ -161,6 +163,8 @@ export interface InfiniteTableMappedState { onKeyDown: InfiniteTableProps['onKeyDown']; onCellClick: InfiniteTableProps['onCellClick']; + wrapRowsHorizontally: InfiniteTableProps['wrapRowsHorizontally']; + rowDetailCache: RowDetailCache; headerOptions: NonUndefined['headerOptions']>; diff --git a/source/src/components/InfiniteTable/vars.css.ts b/source/src/components/InfiniteTable/vars.css.ts index 78651da96..c54361c4e 100644 --- a/source/src/components/InfiniteTable/vars.css.ts +++ b/source/src/components/InfiniteTable/vars.css.ts @@ -53,7 +53,8 @@ export const ThemeVars = createGlobalThemeContract( runtime: { bodyWidth: 'runtime-body-content-width', - totalVisibleColumnsWidth: 'runtime-total-visible-columns-width', + totalVisibleColumnsWidthValue: 'runtime-total-visible-columns-width', + totalVisibleColumnsWidthVar: 'runtime-total-visible-columns-width-var', visibleColumnsCount: 'runtime-visible-columns-count', browserScrollbarWidth: 'runtime-browser-scrollbar-width', }, diff --git a/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts b/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts new file mode 100644 index 000000000..9275a5ec9 --- /dev/null +++ b/source/src/components/VirtualBrain/HorizontalLayoutMatrixBrain.ts @@ -0,0 +1,411 @@ +import { raf } from '../../utils/raf'; +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! + * + */ + +type HorizontalLayoutMatrixBrainOptions = { + isHeader: boolean; + masterBrain?: HorizontalLayoutMatrixBrain; +}; + +export class HorizontalLayoutMatrixBrain extends MatrixBrain implements IBrain { + public visiblePageCount = 0; + public isHorizontalLayoutBrain = true; + + private _totalPageCount: number = 0; + public pageWidth: number = 0; + + public initialCols = 0; + public initialRows = 0; + private initialColWidth: MatrixBrainOptions['colWidth'] = 0; + protected colWidth: ItemSizeFunction = () => 10; + + private options: HorizontalLayoutMatrixBrainOptions; + + constructor(name: string, opts: HorizontalLayoutMatrixBrainOptions) { + super(`HorizontalLayout${name ? `:${name}` : ''}`); + this.options = opts; + + if (this.options.masterBrain) { + this.options.masterBrain.onTotalPageCountChange(() => { + this.updateRenderCount({ horizontal: true, vertical: true }); + }); + } + } + + getRowIndexInPage(rowIndex: number) { + return this.rowsPerPage ? rowIndex % this.rowsPerPage : rowIndex; + } + + getInitialCols() { + return this.initialCols; + } + + getInitialRows() { + return this.initialRows; + } + + getPageIndexForRow(rowIndex: number) { + const pageIndex = Math.floor(rowIndex / this.rowsPerPage); + + return pageIndex; + } + + 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: Math.min(colIndex, this.initialCols), + rowIndex: this.options.isHeader + ? rowIndex + : Math.min(rowIndex, this.initialRows), + }; + } + + 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; + } + 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 verticalChange = rowsChanged || rowHeightChanged || heightChanged; + const horizontalChange = + colsChanged || + colWidthChanged || + widthChanged || + /** when something changes vertically, + * it needs to trigger horizontal change as well since + * the number of "virtual" columns needs to be adjusted + * + * THIS IS VERY IMPORTANT TO HAVE HERE + */ + verticalChange; + + 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); + }; + + getInitialRowHeight() { + if (typeof this.rowHeight !== 'number') { + return this.getRowHeight(0); + } + + return this.rowHeight; + } + + get totalPageCount(): number { + return this.options.masterBrain + ? this.options.masterBrain.totalPageCount + : this._totalPageCount; + } + + set totalPageCount(value: number) { + if (this.options.masterBrain) { + return; + } + this._totalPageCount = value; + } + + doUpdateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { + const rowHeight = this.getInitialRowHeight(); + + // 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 / rowHeight); + + let shouldNotifyTotalPageCountChange = false; + + if (!this.options.masterBrain) { + const prevTotalPageCount = this.totalPageCount; + this.totalPageCount = this.rowsPerPage + ? Math.ceil(this.initialRows / this.rowsPerPage) + : 0; + + if (prevTotalPageCount != this.totalPageCount) { + shouldNotifyTotalPageCountChange = true; + } + } + + this.visiblePageCount = + this.totalPageCount && this.pageWidth + ? Math.max(Math.ceil(this.availableWidth / this.pageWidth), 1) + : 1; + + this.availableRenderHeight = + this.visiblePageCount * this.rowsPerPage * rowHeight; + + this.cols = Math.max( + this.totalPageCount * this.initialCols, + this.initialCols, + ); + this.rows = this.rowsPerPage; + + super.doUpdateRenderCount(which); + + if (shouldNotifyTotalPageCountChange) { + this.notifyTotalPageCountChange(); + } + } + private onTotalPageCountChangeFns: Set<(totalPageCount: number) => void> = + new Set(); + + private notifyTotalPageCountChange() { + if (this.destroyed) { + return; + } + const fns = this.onTotalPageCountChangeFns; + + fns.forEach((fn) => { + raf(() => { + if (this.destroyed) { + return; + } + // #check-for-presence - see above note + if (fns.has(fn)) { + fn(this.totalPageCount); + } + }); + }); + } + + protected onTotalPageCountChange = (fn: (x: number) => void) => { + this.onTotalPageCountChangeFns.add(fn); + + return () => { + this.onTotalPageCountChangeFns.delete(fn); + }; + }; + + public getVirtualColIndex(colIndex: number, opts?: { pageIndex: number }) { + return this.initialCols * (opts?.pageIndex ?? 0) + colIndex; + } + + destroy() { + if (this.destroyed) { + return; + } + super.destroy(); + + this.options.masterBrain = undefined; + this.onTotalPageCountChangeFns.clear(); + } + + getVirtualizedContentSizeFor(direction: 'horizontal' | 'vertical') { + if (direction === 'vertical') { + const rowHeight = this.getInitialRowHeight(); + return 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 000000000..b583debdf --- /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 62106c9a8..3f7c1a1ed 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, @@ -14,13 +23,11 @@ export type SpanFunction = ({ colIndex: number; }) => number; -type RenderRangeType = { +export type RenderRangeType = { startIndex: number; 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,58 @@ 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 isHorizontalLayoutBrain = false; public name: string = ''; - private height: MatrixBrainOptions['height'] = 0; + protected availableHeight: MatrixBrainOptions['height'] = 0; + protected availableRenderHeight: number = 0; + + protected cols: MatrixBrainOptions['cols'] = 0; + protected rows: MatrixBrainOptions['rows'] = 0; + + /** + * This is only here for easier accessing in the renderer, when the horizontal layout is enabled. + * In this way, the API is the same for both brains, so we don't have to cast the brain type in the renderer. + */ + set rowsPerPage(rowsPerPage: number) { + if (rowsPerPage != this._rowsPerPage) { + this._rowsPerPage = rowsPerPage; + } + } - private cols: MatrixBrainOptions['cols'] = 0; - private rows: MatrixBrainOptions['rows'] = 0; + get rowsPerPage() { + return this._rowsPerPage; + } + protected _rowsPerPage = 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,29 +149,32 @@ 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; private verticalRenderCount?: number = undefined; - private horizontalRenderRange: RenderRangeType = { + protected horizontalRenderRange: RenderRangeType = { startIndex: 0, endIndex: 0, }; - private verticalRenderRange: RenderRangeType = { startIndex: 0, endIndex: 0 }; + protected verticalRenderRange: RenderRangeType = { + startIndex: 0, + endIndex: 0, + }; - private extraSpanCells: [number, number][] = []; + protected extraSpanCells: [number, number][] = []; private scrollPosition: ScrollPosition = { scrollLeft: 0, scrollTop: 0 }; @@ -148,7 +188,7 @@ export class MatrixBrain extends Logger { new Set(); private onDestroyFns: VoidFn[] = []; - private destroyed = false; + protected destroyed = false; private onRenderCountChangeFns: Set = new Set(); private onAvailableSizeChangeFns: Set = new Set(); private onScrollStartFns: VoidFunction[] = []; @@ -174,10 +214,16 @@ export class MatrixBrain extends Logger { */ private fixedRowsEnd = 0; - constructor(name?: string) { + constructor(name: string) { const logName = `MatrixBrain${name ? `:${name}` : ''}`; super(logName); - this.name = logName; + this.name = name || 'MatrixBrain'; + + this.update = this.update.bind(this); + this.destroy = this.destroy.bind(this); + + this.getCellOffset = this.getCellOffset.bind(this); + this.reset(); } @@ -214,6 +260,10 @@ export class MatrixBrain extends Logger { this.scrollStopDelay = scrollStopDelay; }; + public getVirtualColIndex(colIndex: number, _opts?: { pageIndex: number }) { + return colIndex; + } + public getRowCount = () => { return this.rows; }; @@ -221,16 +271,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 availableWidth === 'number'; + const heightDefined = typeof availableHeight === 'number'; - const widthDefined = typeof width === 'number'; - const heightDefined = typeof height === 'number'; + const widthChanged = widthDefined && availableWidth !== this.availableWidth; + const heightChanged = + heightDefined && availableHeight !== this.availableHeight; - const widthChanged = widthDefined && width !== this.width; - const heightChanged = heightDefined && height !== this.height; + if (widthChanged) { + this.availableWidth = availableWidth; + this.availableRenderWidth = availableWidth; + } + if (heightChanged) { + this.availableHeight = availableHeight; + this.availableRenderHeight = availableHeight; + } - this.setAvailableSize({ width, height }, { skipUpdateRenderCount: true }); + if (widthChanged || heightChanged) { + this.notifyAvailableSizeChange(); + } const rowsDefined = typeof rows === 'number'; const colsDefined = typeof cols === 'number'; @@ -240,6 +314,7 @@ export class MatrixBrain extends Logger { if (rowsDefined) { this.rows = rows; + this.rowsPerPage = rows; } if (colsDefined) { this.cols = cols; @@ -262,17 +337,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 +383,80 @@ 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; + /** + * + * @param options.left - if true, extends the left side with the amount of current visible columns, otherwise with the specified number + * @param options.right - if true, extends the right side with the amount of current visible columns, otherwise with the specified number + */ + public extendRenderRange(options: { + left?: number | boolean; + right?: number | boolean; + }) { + const leftAmount = + typeof options.left === 'number' + ? options.left + : options.left === true + ? this.getInitialCols() + : 0; + const rightAmount = + typeof options.right === 'number' + ? options.right + : options.right === true + ? this.getInitialCols() + : 0; + + const currentRenderCount = this.horizontalRenderCount; + + const restore = () => { + this.setRenderCount({ + horizontal: currentRenderCount, + vertical: undefined, + }); + }; - this.updateRenderCount({ - horizontal: !colsSame, - vertical: !rowsSame, + const { start, end } = this.getRenderRange(); + const [startRow, startCol] = start; + const [endRow, endCol] = end; + + this.setRenderRange({ + horizontal: { + startIndex: Math.max(0, startCol - leftAmount), + endIndex: Math.min(this.cols, endCol + rightAmount), + }, + vertical: { + startIndex: startRow, + endIndex: endRow, + }, }); - }; - - 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 }); + return restore; } - updateRenderCount = (which: WhichDirection = ALL_DIRECTIONS) => { + public updateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { // if (this._updateRenderCountRafId) { // cancelAnimationFrame(this._updateRenderCountRafId); // } @@ -393,23 +466,37 @@ export class MatrixBrain extends Logger { // delete this._updateRenderCountRafId; // }); - }; + } - private doUpdateRenderCount = (which: WhichDirection = ALL_DIRECTIONS) => { - if (!this.width || !this.height) { - this.setRenderCount({ horizontal: 0, vertical: 0 }); + protected doUpdateRenderCount(which: WhichDirection = ALL_DIRECTIONS) { + if (!this.availableWidth || !this.availableHeight) { + const count: Parameters[0] = { + horizontal: undefined, + vertical: undefined, + }; + + if (!this.availableWidth) { + count.horizontal = 0; + } + if (!this.availableHeight) { + count.vertical = 0; + } + + this.setRenderCount(count); + // DON'T uncomment the return + // return } 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 +545,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 +566,9 @@ export class MatrixBrain extends Logger { this.notifyScrollChange(); } - }; + } - private notifyAvailableSizeChange = () => { + protected notifyAvailableSizeChange = () => { if (this.destroyed) { return; } @@ -505,7 +592,7 @@ export class MatrixBrain extends Logger { }); }; - private notifyRenderRangeChange = () => { + protected notifyRenderRangeChange() { if (this.destroyed) { return; } @@ -524,8 +611,8 @@ export class MatrixBrain extends Logger { } }); }); - }; - private notifyVerticalRenderRangeChange = () => { + } + protected notifyVerticalRenderRangeChange = () => { if (this.destroyed) { return; } @@ -545,7 +632,7 @@ export class MatrixBrain extends Logger { }); }); }; - private notifyHorizontalRenderRangeChange = () => { + protected notifyHorizontalRenderRangeChange = () => { if (this.destroyed) { return; } @@ -582,11 +669,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 +930,7 @@ export class MatrixBrain extends Logger { 'horizontal', this.colWidth, this.cols, - this.getAvailableSize(), + this.getAvailableRenderSize(), ); } if (recomputeVertical) { @@ -850,7 +938,7 @@ export class MatrixBrain extends Logger { 'vertical', this.rowHeight, this.rows, - this.getAvailableSize(), + this.getAvailableRenderSize(), ); } const result = { @@ -1134,17 +1222,29 @@ 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'), }; - }; + } + + getInitialRowHeight() { + return this.rowHeight; + } + + getInitialCols() { + return this.cols; + } + + getInitialRows() { + return this.rows; + } - 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 +1268,9 @@ export class MatrixBrain extends Logger { result = itemOffsetCache[itemIndex]; } return result; - }; + } - private computeCacheFor = ( + protected computeCacheFor = ( itemIndex: number, direction: 'horizontal' | 'vertical', ) => { @@ -1432,14 +1532,22 @@ export class MatrixBrain extends Logger { return cachedSize; }; - getTotalSize = () => { + getRowIndexInPage(rowIndex: number) { + return rowIndex; + } + + getPageIndexForRow(_rowIndex: number) { + return 0; + } + + 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 +1579,7 @@ export class MatrixBrain extends Logger { } return result; - }; + } setRenderRange = ({ horizontal, @@ -1602,8 +1710,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, }; }; @@ -1614,7 +1729,7 @@ export class MatrixBrain extends Logger { } } - destroy = () => { + destroy() { if (this.destroyed) { return; } @@ -1629,9 +1744,9 @@ export class MatrixBrain extends Logger { this.onScrollFns = []; this.onScrollStartFns = []; this.onScrollStopFns = []; - this.onRenderCountChangeFns = new Set(); - this.onRenderRangeChangeFns = new Set(); - this.onVerticalRenderRangeChangeFns = new Set(); - this.onHorizontalRenderRangeChangeFns = new Set(); - }; + this.onRenderCountChangeFns.clear(); + this.onRenderRangeChangeFns.clear(); + this.onVerticalRenderRangeChangeFns.clear(); + this.onHorizontalRenderRangeChangeFns.clear(); + } } diff --git a/source/src/utils/debugPackage.ts b/source/src/utils/debugPackage.ts index 336c571cc..3c2cd22c9 100644 --- a/source/src/utils/debugPackage.ts +++ b/source/src/utils/debugPackage.ts @@ -3,10 +3,10 @@ import { getGlobal } from './getGlobal'; // colors take from the `debug` package on npm const COLORS = [ - '#0000CC', - '#0000FF', - '#0033CC', - '#0033FF', + // '#0000CC', + // '#0000FF', + // '#0033CC', + // '#0033FF', '#0066CC', '#0066FF', '#0099CC', @@ -340,7 +340,7 @@ function debugPackage(channelName: string): any { logFn(...theArgs); } else { - logFn(`%c[${channel}]`, `color: ${color}`, ...args); + logFn(`%c[${channel}]%c %s`, `color: ${color}`, '', ...args); } } },