Last active
October 15, 2025 18:10
-
-
Save zax4r0/5300b8ac51316ae962e4529b911bea7e to your computer and use it in GitHub Desktop.
cell.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // FastGrid.tsx (main component) | |
| import clsx from "clsx"; | |
| import Stats from "stats.js"; | |
| import { Analytics } from "@vercel/analytics/react"; | |
| import { FilterCell, Grid, type CellRenderer } from "grid"; | |
| import { COLUMNS, generateRows } from "./generateRows"; | |
| import React, { useState, useRef, useEffect } from "react"; | |
| // Human-friendly custom React cell renderer: Highlights numeric values > 50 in green for the "amount" column (index 4) | |
| // For other columns, just displays the value cleanly. Easy to extend with colIndex logic. | |
| const MyCustomReactCellComponent: React.FC<{ value: string | number; colIndex: number }> = ({ value, colIndex }) => { | |
| // Example: Only apply highlighting logic to column 4 (assuming it's the numeric "amount" column) | |
| let displayValue = String(value); | |
| let textClass = "text-gray-800"; | |
| let extraContent = null; | |
| if (colIndex === 4) { | |
| const numValue = typeof value === 'number' ? value : parseFloat(value as string); | |
| const isHighlighted = !isNaN(numValue) && numValue > 50; | |
| if (isHighlighted) { | |
| textClass = "text-green-600 font-semibold"; | |
| extraContent = <span className="ml-1 text-green-500">✓</span>; // Simple icon for highlight | |
| } | |
| } | |
| // For other columns, you could add more colIndex-specific logic here, e.g.: | |
| // if (colIndex === 3) { displayValue = value.toUpperCase(); } // e.g., uppercase emails | |
| return ( | |
| <div className="flex items-center w-full h-full"> | |
| <span className={clsx("font-mono text-[14px]", textClass)}> | |
| {displayValue} | |
| </span> | |
| {extraContent} | |
| </div> | |
| ); | |
| }; | |
| export const FastGrid = () => { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [grid, setGrid] = useState<Grid | null>(null); | |
| const [speed, setSpeed] = useState(0); | |
| const [rowCount, setRowCount] = useState(100_000); | |
| const [stressTest, setStressTest] = useState(false); | |
| const [loadingRows, setLoadingRows] = useState(false); | |
| const [autoScroller, setAutoScroller] = useState<AutoScroller | null>(null); | |
| // Human-friendly API: Define custom renderers by column name (from COLUMNS array) | |
| // Example: const customRenderersByName: Record<string, CellRenderer> = { [COLUMNS[4]]: MyCustomReactCellComponent }; // Only for "amount" | |
| // To apply to specific columns: if (customRenderersByName[colName]) acc[index] = customRenderersByName[colName]; | |
| // Here, applying the same custom React cell to ALL columns for demonstration | |
| const customRenderersByName: Record<string, CellRenderer> = COLUMNS.reduce((acc, colName) => { | |
| acc[colName] = MyCustomReactCellComponent; | |
| return acc; | |
| }, {} as Record<string, CellRenderer>); | |
| // Map by-name renderers to by-index for the Grid (keeps internal API simple) | |
| const customColumnRenderers: Record<number, CellRenderer> = COLUMNS.reduce((acc, colName, index) => { | |
| const renderer = customRenderersByName[colName]; | |
| if (renderer) { | |
| acc[index] = renderer; | |
| } | |
| return acc; | |
| }, {} as Record<number, CellRenderer>); | |
| useEffect(() => { | |
| const container = containerRef.current; | |
| if (container == null) { | |
| return; | |
| } | |
| // init grid | |
| const t0 = performance.now(); | |
| // Pass customColumnRenderers to enable React cells (defaults to {} if omitted) | |
| const grid = new Grid(container, [], COLUMNS, customColumnRenderers); | |
| setGrid(grid); | |
| console.info("Ms to intitialize grid:", performance.now() - t0); | |
| setLoadingRows(true); | |
| generateRows(rowCount, grid, () => setLoadingRows(false)); | |
| // setup autoscroller | |
| const autoScroller = new AutoScroller(grid); | |
| setAutoScroller(autoScroller); | |
| (window as any).__grid = grid; | |
| return () => { | |
| grid.destroy(); | |
| }; | |
| }, [rowCount]); | |
| useEffect(() => { | |
| if (grid == null || !stressTest) return; | |
| const id = setInterval(() => { | |
| const filters = grid.rowManager.view.filter; | |
| if (filters[4] == null || filters[4].length < 5) { | |
| filters[4] = | |
| (filters[4] ?? "") + Math.floor(Math.random() * 10).toString(); | |
| } else { | |
| delete filters[4]; | |
| } | |
| // manually trigger refresh of filter cells.. make it part of updating the filter | |
| for (const header of grid.headerRows) { | |
| for (const cell of Object.values(header.cellComponentMap)) { | |
| if (cell instanceof FilterCell) { | |
| if (cell.index === 4) { | |
| cell.el.style.backgroundColor = "rgb(239, 68, 68)"; | |
| cell.input.style.backgroundColor = "rgb(239, 68, 68)"; | |
| cell.input.style.color = "white"; | |
| cell.input.placeholder = ""; | |
| cell.arrow.style.fill = "white"; | |
| cell.syncToFilter(); | |
| } | |
| } | |
| } | |
| } | |
| grid.rowManager.runFilter(); | |
| }, 333); | |
| return () => { | |
| // manually trigger refresh of filter cells.. make it part of updating the filter | |
| for (const header of grid.headerRows) { | |
| for (const cell of Object.values(header.cellComponentMap)) { | |
| if (cell instanceof FilterCell) { | |
| if (cell.index === 4) { | |
| delete grid.rowManager.view.filter[4]; | |
| cell.el.style.backgroundColor = "white"; | |
| cell.input.style.backgroundColor = "white"; | |
| cell.input.style.color = "black"; | |
| cell.input.placeholder = "filter..."; | |
| cell.arrow.style.fill = "black"; | |
| cell.syncToFilter(); | |
| } | |
| } | |
| } | |
| } | |
| grid.rowManager.runFilter(); | |
| clearInterval(id); | |
| }; | |
| }, [grid, stressTest]); | |
| useEffect(() => { | |
| if (autoScroller == null) return; | |
| autoScroller.start(speed === 0 ? 0 : Math.exp(speed / 15)); | |
| }, [autoScroller, speed]); | |
| return ( | |
| <> | |
| <Analytics /> | |
| <h1 className="self-start text-lg font-bold sm:self-center md:text-3xl"> | |
| World's most performant DOM-based table | |
| </h1> | |
| <div className="mt-1 self-start max-md:mt-2 sm:self-center"> | |
| Try make the fps counter drop by filtering, sorting, and scrolling | |
| simultaneously | |
| </div> | |
| <div className="mb-4 mt-1 self-start text-sm max-md:mt-2 sm:self-center sm:text-[13px]"> | |
| See code: | |
| <a | |
| className="ml-1 text-blue-600 underline hover:text-blue-800" | |
| href="https://github.com/gabrielpetersson/fast-grid/" | |
| > | |
| https://github.com/gabrielpetersson/fast-grid/ | |
| </a> | |
| </div> | |
| <div | |
| className={clsx( | |
| "flex w-full select-none flex-wrap justify-between gap-2 py-2", | |
| loadingRows && "pointer-events-none select-none opacity-60" | |
| )} | |
| > | |
| <div className="hidden w-[150px] md:block" /> | |
| <div className="flex gap-2 text-[11px] md:gap-8 md:text-[13px]"> | |
| <div className="flex items-center"> | |
| <span className="mr-2 whitespace-nowrap">Scroll speed:</span> | |
| <input | |
| type="range" | |
| min="0" | |
| max="100" | |
| value={speed} | |
| onChange={(e) => setSpeed(Number(e.target.value))} | |
| className={clsx( | |
| "h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-300", | |
| speed === 100 && | |
| "[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:bg-red-500 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500" | |
| )} | |
| /> | |
| </div> | |
| <button | |
| className={clsx( | |
| "flex h-[28px] w-[200px] items-center justify-center gap-0.5 rounded bg-blue-500 text-white hover:opacity-95 active:opacity-90", | |
| stressTest && "bg-red-500" | |
| )} | |
| onClick={() => { | |
| if (stressTest) { | |
| setStressTest(false); | |
| setSpeed(0); | |
| } else { | |
| setStressTest(true); | |
| setSpeed(100); | |
| } | |
| }} | |
| > | |
| {stressTest && ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| className="h-[14px] w-[14px]" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| > | |
| <line x1="18" y1="6" x2="6" y2="18" /> | |
| <line x1="6" y1="6" x2="18" y2="18" /> | |
| </svg> | |
| )} | |
| {stressTest ? "Filtering 3 times per second" : "Stress test"} | |
| </button> | |
| </div> | |
| <select | |
| value={rowCount} | |
| onChange={(e) => { | |
| if (grid == null) return; | |
| setRowCount(Number(e.target.value)); | |
| }} | |
| className="hidden h-[28px] w-[150px] items-center justify-center rounded border border-gray-800 bg-white text-[12px] text-gray-700 shadow-[rgba(0,_0,_0,_0.1)_0px_0px_2px_1px] md:flex" | |
| > | |
| <option value={10}>10 rows</option> | |
| <option value={10_000}>10 000 rows</option> | |
| <option value={100_000}>100 000 rows</option> | |
| <option value={200_000}>200 000 rows</option> | |
| <option value={500_000}>500 000 rows</option> | |
| <option value={1_000_000}> | |
| 1 000 000 rows (might run out of ram) | |
| </option> | |
| <option value={2_000_000}> | |
| 2 000 000 rows (might run out of ram) | |
| </option> | |
| <option value={5_000_000}> | |
| 5 000 000 rows (might run out of ram) | |
| </option> | |
| <option value={10_000_000}> | |
| 10 000 000 rows (might run out of ram) | |
| </option> | |
| </select> | |
| </div> | |
| <div | |
| ref={containerRef} // attaching grid here | |
| style={{ | |
| contain: "strict", | |
| }} | |
| className={clsx( | |
| "relative box-border w-full flex-1 overflow-clip border border-gray-700 bg-white", | |
| loadingRows && "pointer-events-none opacity-70" | |
| )} | |
| /> | |
| </> | |
| ); | |
| }; | |
| const setupFPS = () => { | |
| const stats = new Stats(); | |
| stats.showPanel(0); | |
| stats.dom.style.top = "unset"; | |
| stats.dom.style.left = "unset"; | |
| stats.dom.style.bottom = "0"; | |
| stats.dom.style.right = "0"; | |
| for (const child of stats.dom.children) { | |
| // @ts-expect-error ddd | |
| child.style.width = "160px"; | |
| // @ts-expect-error ddd | |
| child.style.height = "96px"; | |
| } | |
| document.body.appendChild(stats.dom); | |
| const animate = () => { | |
| stats.update(); | |
| window.requestAnimationFrame(animate); | |
| }; | |
| window.requestAnimationFrame(animate); | |
| }; | |
| setupFPS(); | |
| class AutoScroller { | |
| grid: Grid; | |
| running: boolean; | |
| toBottom: boolean; | |
| version: number; | |
| constructor(grid: Grid) { | |
| this.grid = grid; | |
| this.running = true; | |
| this.toBottom = true; | |
| this.version = 0; | |
| } | |
| start(speed: number) { | |
| this.version++; | |
| const currentVersion = this.version; | |
| const cb = () => { | |
| const state = this.grid.getState(); | |
| if (this.version !== currentVersion) { | |
| return; | |
| } | |
| if ( | |
| this.grid.offsetY > | |
| state.tableHeight - this.grid.viewportHeight - 1 | |
| ) { | |
| this.toBottom = false; | |
| } else if (this.grid.offsetY <= 0) { | |
| this.toBottom = true; | |
| } | |
| const delta = this.toBottom ? speed : -speed; | |
| const wheelEvent = new WheelEvent("wheel", { | |
| deltaY: delta, | |
| deltaMode: 0, | |
| }); | |
| this.grid.container.dispatchEvent(wheelEvent); | |
| window.requestAnimationFrame(cb); | |
| }; | |
| window.requestAnimationFrame(cb); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Grid } from "./grid"; | |
| export const CELL_WIDTH = 200; | |
| export type CellComponent = { | |
| id: number; | |
| el: HTMLDivElement; | |
| _offset: number; | |
| setContent: (text: string | number) => void; | |
| setOffset: (offset: number, force?: boolean) => void; | |
| reuse: ( | |
| id: number, | |
| offset: number, | |
| text: string | number, | |
| index: number | |
| ) => void; | |
| }; | |
| export class StringCell implements CellComponent { | |
| id: number; | |
| el: HTMLDivElement; | |
| _offset: number; | |
| constructor(id: number, offset: number, text: string | number) { | |
| this.id = id; | |
| this._offset = offset; | |
| this.el = document.createElement("div"); | |
| // NOTE(gab): fonts are super expensive, might be more simple fonts that are faster to render? testing to render a cursive text with subpixel antialiasing, vs | |
| // rendering monospace text with text smoothing | |
| // https://codesandbox.io/s/performance-test-disabling-text-antialiasing-om6f3q?file=/index.js | |
| // NOTE(gab): align-items actually has a super slight imapact on Layerize time, using padding for now | |
| this.el.className = | |
| "flex h-full pt-[7px] border-[0] border-r border-b border-solid border-gray-700 text-gray-800 box-border cursor-default pl-[6px] absolute left-0 font-mono text-[14px]"; | |
| this.el.style.width = `${CELL_WIDTH}px`; | |
| this.setOffset(this._offset, true); | |
| this.setContent(text); | |
| } | |
| setContent(text: string | number) { | |
| this.el.innerText = String(text); | |
| } | |
| setOffset(offset: number, force: boolean = false) { | |
| if (force || offset !== this._offset) { | |
| this.el.style.transform = `translateX(${offset}px)`; | |
| } | |
| this._offset = offset; | |
| } | |
| reuse(id: number, offset: number, text: string | number) { | |
| this.id = id; | |
| this.setOffset(offset, true); | |
| this.setContent(text); | |
| } | |
| } | |
| export class HeaderCell implements CellComponent { | |
| id: number; | |
| el: HTMLDivElement; | |
| _offset: number; | |
| constructor(id: number, offset: number, text: string | number) { | |
| this.id = id; | |
| this._offset = offset; | |
| this.el = document.createElement("div"); | |
| this.el.className = | |
| "flex h-full pt-[5px] border-[0] border-r border-b-2 border-solid border-gray-700 text-gray-800 box-border cursor-default pl-[6px] absolute left-0 overflow-clip"; | |
| this.el.style.width = `${CELL_WIDTH}px`; | |
| // extra header styles | |
| this.el.style.backgroundColor = "white"; | |
| this.el.style.fontWeight = "500"; | |
| this.el.style.fontFamily = "monospace"; | |
| this.el.style.fontSize = "15px"; | |
| this.setOffset(this._offset, true); | |
| this.setContent(text); | |
| } | |
| setContent(text: string | number) { | |
| this.el.innerText = String(text); | |
| } | |
| setOffset(offset: number, force: boolean = false) { | |
| if (force || offset !== this._offset) { | |
| this.el.style.transform = `translateX(${offset}px)`; | |
| } | |
| this._offset = offset; | |
| } | |
| reuse(id: number, offset: number, text: string | number) { | |
| this.id = id; | |
| this.setOffset(offset, true); | |
| this.setContent(text); | |
| } | |
| } | |
| export class FilterCell implements CellComponent { | |
| grid: Grid; | |
| index: number; | |
| id: number; | |
| el: HTMLDivElement; | |
| input: HTMLInputElement; | |
| arrow: SVGSVGElement; | |
| _offset: number; | |
| constructor( | |
| id: number, | |
| offset: number, | |
| text: string | number, | |
| grid: Grid, | |
| index: number | |
| ) { | |
| this.grid = grid; | |
| this.index = index; | |
| this.id = id; | |
| this._offset = offset; | |
| this.el = document.createElement("div"); | |
| this.el.className = | |
| "flex h-full pt-[5px] border-[0] border-r border-b border-solid border-gray-700 text-gray-800 box-border cursor-default pl-[6px] absolute left-0 overflow-clip"; | |
| this.el.style.width = `${CELL_WIDTH}px`; | |
| this.input = document.createElement("input"); | |
| this.input.type = "text"; | |
| this.input.value = String(text); | |
| this.input.className = | |
| "w-full h-full border-none outline-none text-[13px] select-none"; | |
| this.input.style.fontFamily = "monospace"; | |
| this.input.placeholder = "filter..."; | |
| this.input.addEventListener("input", this.onInputChange); | |
| this.el.appendChild(this.input); | |
| const arrowContainer = document.createElement("div"); | |
| arrowContainer.className = | |
| "flex items-center justify-center w-[35px] h-[28px] cursor-pointer"; | |
| arrowContainer.addEventListener("click", this.onArrowClick); | |
| this.arrow = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | |
| this.arrow.setAttribute("xmlns", "http://www.w3.org/2000/svg"); | |
| this.arrow.setAttribute("viewBox", "0 0 24 24"); | |
| this.arrow.setAttribute( | |
| "class", | |
| "w-5 h-5 fill-current transition-transform duration-200 rotate-90" | |
| ); | |
| const mainPath = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "path" | |
| ); | |
| mainPath.setAttribute( | |
| "d", | |
| "M12 3.75l-6.5 6.5L7 11.75l3.5-3.5V20h3V8.25l3.5 3.5 1.5-1.5z" | |
| ); | |
| this.arrow.appendChild(mainPath); | |
| const arrowHead = document.createElementNS( | |
| "http://www.w3.org/2000/svg", | |
| "path" | |
| ); | |
| arrowHead.setAttribute("d", "M12 5.5L7.5 10h9z"); | |
| arrowHead.setAttribute("fill", "currentColor"); | |
| arrowHead.setAttribute("opacity", "0.3"); | |
| this.arrow.appendChild(arrowHead); | |
| arrowContainer.appendChild(this.arrow); | |
| this.el.appendChild(arrowContainer); | |
| this.syncToFilter(); | |
| this.setOffset(this._offset, true); | |
| } | |
| private onInputChange = () => { | |
| if (this.input.value === "") { | |
| delete this.grid.rowManager.view.filter[this.index]; | |
| } else { | |
| this.grid.rowManager.view.filter[this.index] = this.input.value; | |
| } | |
| this.grid.rowManager.runFilter(); | |
| }; | |
| private onArrowClick = () => { | |
| const idx = this.grid.rowManager.view.sort.findIndex( | |
| (sort) => sort.column === this.index | |
| ); | |
| const currentSort = idx !== -1 ? this.grid.rowManager.view.sort[idx] : null; | |
| if (currentSort == null) { | |
| this.grid.rowManager.view.sort.push({ | |
| direction: "descending", | |
| column: this.index, | |
| }); | |
| this.arrow.style.transform = "rotate(180deg)"; | |
| } else if (currentSort.direction === "descending") { | |
| currentSort.direction = "ascending"; | |
| this.arrow.style.transform = "rotate(0deg)"; | |
| } else { | |
| this.grid.rowManager.view.sort.splice(idx, 1); | |
| this.arrow.style.transform = "rotate(90deg)"; | |
| } | |
| this.grid.rowManager.runSort(); | |
| }; | |
| syncToFilter = () => { | |
| if (this.index in this.grid.rowManager.view.filter) { | |
| this.input.value = this.grid.rowManager.view.filter[this.index]; | |
| } else { | |
| this.input.value = ""; | |
| } | |
| if (this.index in this.grid.rowManager.view.sort) { | |
| const sort = this.grid.rowManager.view.sort.find( | |
| (sort) => sort.column === this.index | |
| ); | |
| if (sort == null) { | |
| this.arrow.style.transform = "rotate(90deg)"; | |
| } else if (sort.direction === "descending") { | |
| this.arrow.style.transform = "rotate(180deg)"; | |
| } else { | |
| this.arrow.style.transform = "rotate(0deg)"; | |
| } | |
| } | |
| }; | |
| setContent = () => { | |
| this.syncToFilter(); | |
| }; | |
| setOffset = (offset: number, force: boolean = false) => { | |
| if (force || offset !== this._offset) { | |
| this.el.style.transform = `translateX(${offset}px)`; | |
| } | |
| this._offset = offset; | |
| }; | |
| reuse = ( | |
| id: number, | |
| offset: number, | |
| _text: string | number, | |
| index: number | |
| ) => { | |
| this.id = id; | |
| this.index = index; | |
| this.setOffset(offset, true); | |
| this.syncToFilter(); | |
| }; | |
| } | |
| import React from "react"; | |
| import { createRoot, Root } from "react-dom/client"; | |
| export class ReactCellWrapper implements CellComponent { | |
| id: number; | |
| el: HTMLDivElement; | |
| _offset: number; | |
| private root: Root; | |
| private reactComponent: React.ComponentType<{ value: string | number; colIndex: number }>; | |
| private colIndex: number; | |
| constructor( | |
| reactComponent: React.ComponentType<{ value: string | number; colIndex: number }>, | |
| id: number, | |
| offset: number, | |
| text: string | number, | |
| colIndex: number | |
| ) { | |
| this.reactComponent = reactComponent; | |
| this.id = id; | |
| this.colIndex = colIndex; | |
| this._offset = offset; | |
| this.el = document.createElement("div"); | |
| // Reusing styles from StringCell for visual consistency in data cells | |
| this.el.className = | |
| "flex h-full pt-[7px] border-[0] border-r border-b border-solid border-gray-700 text-gray-800 box-border cursor-default pl-[6px] absolute left-0 font-mono text-[14px]"; | |
| this.el.style.width = `${CELL_WIDTH}px`; | |
| this.root = createRoot(this.el); | |
| this.setOffset(offset, true); | |
| this.setContent(text); | |
| } | |
| setContent(text: string | number) { | |
| this.root.render( | |
| React.createElement(this.reactComponent, { | |
| value: text, | |
| colIndex: this.colIndex, | |
| }) | |
| ); | |
| } | |
| setOffset(offset: number, force: boolean = false) { | |
| if (force || offset !== this._offset) { | |
| this.el.style.transform = `translateX(${offset}px)`; | |
| } | |
| this._offset = offset; | |
| } | |
| reuse(id: number, offset: number, text: string | number, index: number) { | |
| this.id = id; | |
| this.colIndex = index; | |
| this.setOffset(offset, true); | |
| this.setContent(text); | |
| } | |
| destroy() { | |
| // Clean up the React root to prevent memory leaks on cell reuse/removal | |
| this.root.unmount(); | |
| } | |
| } | |
| export type CellRenderer = | |
| | typeof StringCell | |
| | typeof HeaderCell | |
| | typeof FilterCell | |
| | React.ComponentType<{ value: string | number; colIndex: number }>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| CELL_WIDTH, | |
| CellComponent, | |
| HeaderCell, | |
| FilterCell, | |
| StringCell, | |
| ReactCellWrapper, | |
| } from "./cell"; | |
| import { Grid } from "./grid"; | |
| export type Cell = { | |
| id: number; | |
| v: string | number; | |
| // val: number; | |
| }; | |
| export interface Row { | |
| id: number; | |
| cells: Cell[]; | |
| } | |
| type CellRenderer = typeof StringCell | typeof HeaderCell | typeof FilterCell; | |
| export class RowComponent { | |
| id: number; | |
| el: HTMLDivElement; | |
| cells: Cell[]; | |
| _offset: number; | |
| defaultCellRenderer: CellRenderer; // Renamed for clarity; this is the fallback for columns without specific renderers | |
| columnCellRenderers?: Record<number, CellRenderer>; // Optional per-column overrides, including React components | |
| cellComponentMap: Record<string, CellComponent>; | |
| grid: Grid; | |
| constructor( | |
| grid: Grid, | |
| id: number, | |
| cells: Cell[], | |
| offset: number, | |
| defaultCellRenderer: CellRenderer, // Made explicit as default | |
| columnCellRenderers?: Record<number, CellRenderer> // New: allows per-column custom renderers (vanilla or React) | |
| ) { | |
| this.grid = grid; | |
| this.id = id; | |
| this.cells = cells; | |
| this._offset = offset; | |
| this.defaultCellRenderer = defaultCellRenderer; | |
| this.columnCellRenderers = columnCellRenderers; | |
| this.cellComponentMap = {}; | |
| this.el = document.createElement("div"); | |
| this.el.className = "absolute top-0 h-[32px]"; | |
| // eh temporary header hack, make this passable | |
| if (defaultCellRenderer !== StringCell) { | |
| this.el.style.zIndex = "1"; | |
| } | |
| this.setOffset(this._offset, true); | |
| this.renderCells(); | |
| } | |
| destroy() { | |
| // TODO(gab): can speed be improved? | |
| // https://github.com/brianmhunt/knockout-fast-foreach/issues/37 | |
| // TODO(gab): should not need this, but crashes on my other computer otherwise. check | |
| for (const [id, cell] of Object.entries(this.cellComponentMap)) { | |
| cell.destroy?.(); // Call destroy if available (e.g., for React cells) | |
| delete this.cellComponentMap[id]; | |
| } | |
| if (this.grid.container.contains(this.el)) { | |
| this.grid.container.removeChild(this.el); | |
| } else { | |
| console.error("row component already removed"); | |
| } | |
| } | |
| setOffset(offset: number, force: boolean = false) { | |
| if (force || offset != this._offset) { | |
| this.el.style.transform = `translateY(${offset}px)`; | |
| } | |
| this._offset = offset; | |
| } | |
| renderCells() { | |
| const state = this.grid.getState(); | |
| const renderCells: Record<string, true> = {}; | |
| for (let i = state.startCell; i < state.endCell; i++) { | |
| const cell = this.cells[i]; | |
| renderCells[cell.id] = true; | |
| } | |
| const removeCells: CellComponent[] = []; | |
| for (const id in this.cellComponentMap) { | |
| if (id in renderCells) { | |
| continue; | |
| } | |
| const cell = this.cellComponentMap[id]!; | |
| removeCells.push(cell); | |
| } | |
| for (let i = state.startCell; i < state.endCell; i++) { | |
| const cell = this.cells[i]!; | |
| const offset = state.cellOffset + (i - state.startCell) * CELL_WIDTH; | |
| const renderer = this.columnCellRenderers | |
| ? this.columnCellRenderers[i] || this.defaultCellRenderer | |
| : this.defaultCellRenderer; | |
| // Determine if this is a vanilla class-based renderer or a React component | |
| const prototype = (renderer as any).prototype; | |
| const isVanilla = prototype && "setContent" in prototype; | |
| const isFilter = renderer === FilterCell; | |
| const existingCell = this.cellComponentMap[cell.id]; | |
| if (existingCell != null) { | |
| existingCell.setOffset(offset); | |
| continue; | |
| } | |
| const reuseCell = removeCells.pop(); | |
| if (reuseCell != null) { | |
| delete this.cellComponentMap[reuseCell.id]; | |
| if (isVanilla) { | |
| // Vanilla reuse always passes index for consistency | |
| (reuseCell as any).reuse(cell.id, offset, cell.v, i); | |
| } else { | |
| // React wrapper reuse | |
| (reuseCell as ReactCellWrapper).reuse(cell.id, offset, cell.v, i); | |
| } | |
| this.cellComponentMap[cell.id] = reuseCell; | |
| continue; | |
| } | |
| let newCell: CellComponent; | |
| if (!isVanilla) { | |
| // Create React wrapper for custom React cells | |
| newCell = new ReactCellWrapper( | |
| renderer as React.ComponentType<{ value: string | number; colIndex: number }>, | |
| cell.id, | |
| offset, | |
| cell.v, | |
| i | |
| ); | |
| } else { | |
| // Vanilla cell creation | |
| const args = [cell.id, offset, cell.v]; | |
| if (isFilter) { | |
| args.push(this.grid, i); | |
| } | |
| newCell = new (renderer as any)(...args); | |
| } | |
| this.el.appendChild(newCell.el); | |
| this.cellComponentMap[newCell.id] = newCell; | |
| } | |
| for (const cell of removeCells) { | |
| cell.destroy?.(); // Clean up if destroy is available (e.g., unmount React) | |
| delete this.cellComponentMap[cell.id]; | |
| this.el.removeChild(cell.el); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment