# Rebuild list-table-v2 with TanStack + pretext ## Goal Replace `el-table-v2` dependency with a custom implementation using: - **@chenglou/pretext** for text measurement (column widths, row heights for plain text) - **Custom prefix-sum virtualizer** (pretext-table style, lightweight) - **TanStack Table** for headless table logic (optional, depends on complexity) Keep the existing prop interface of `list-table-v2.vue` so consumers don't need changes. ## What I already know ### Existing Component (`list-table-v2.vue`) - Vue 3 + TypeScript component - Props: `data`, `columns` (with key, dataKey, name, i18n, type, width, minWidth, fixed, align, slot, dict, timestamp, filesize, cellRenderer, headerCellRenderer), `page`, `height`, `minHeight`, `maxHeight`, `example`, `rowKey`, `selectKey`, `treeProps`, `lazy`, `border`, `timestampFormat`, `rowHeight`, `estimatedRowHeight`, `headerHeight`, `debug` - Emits: `query`, `selection-change`, `row-click`, `cell-click` - Uses mini hidden table for row height probing - Uses `el-auto-resizer` + `el-table-v2` - Features: pagination, fixed columns, sorting via slot, dict/timestamp/filesize formatting ### pretext-table (React) Implementation Insights - **@chenglou/pretext** two-phase API: - `prepare(text, font)` → cached handle (Canvas measureText for segmentation) - `layout(prepared, maxWidth, lineHeight)` → `{ height, lineCount }` (pure arithmetic, no DOM) - **Column width**: Binary search with pretext to find minimum single-line width. Samples rows, measures header + cell text widths, respects minWidth/maxWidth - **Row height**: `prepare()` per cell text, `layout()` at column width, takes max cell height + padding - **Virtual scrolling**: Custom binary search over prefix-sum offsets (NOT TanStack Virtual) - Does NOT use TanStack Table or TanStack Virtual ### Available Packages - `@tanstack/vue-table` v8.21.3 - headless Vue 3 table - `@tanstack/vue-virtual` v3.13.23 - headless Vue 3 virtualization - `@chenglou/pretext` v0.0.3 - text measurement ## Virtualization Approach: Custom Prefix-Sum (pretext-table Style) Chosen over TanStack Virtual because: 1. Lightweight — binary search + prefix-sum offsets, no library dependency 2. Works naturally with pretext — all row heights pre-computed via `layout()`, just math 3. Full control over how pretext integrates ### Algorithm 1. Pretext computes ALL row heights upfront (pure arithmetic, no DOM) 2. Build prefix-sum offset array: `offsets[i] = sum(heights[0..i-1])` 3. Binary search on scroll to find visible range: O(log n) 4. Render only visible rows + overscan ### cellRenderer Return Types **Plain VNode (backward compatible):** ```typescript cellRenderer: ({ data }) => {data} ``` **Size-hinted VNode (new):** ```typescript cellRenderer: ({ data }) => ({ vnode: {data}, minHeight: 30, // pixel hint for row height estimation minWidth: 100 // pixel hint for column width estimation (no runtime augmentation) }) ``` ### Runtime Augmentation for Row Heights For columns with custom `cellRenderer` (where pretext can't measure): 1. **Initial**: Use `minHeight` from size-hinted return, or sensible default (44px) 2. **After mount**: Measure actual DOM height per row using ResizeObserver 3. **Running average per column**: Maintain `Map` 4. **Self-adjust**: When average shifts >10%, recompute offsets and re-render ### Column Width Strategy (Pre-computed, No Runtime Augmentation) Flex parameters computed via pretext-measured sampling: 1. **Sample**: For each column, sample values from data rows 2. **Measure**: For each sampled value, measure "shrink wrap" width via `walkLineRanges` (finds widest line, handles `\n`) 3. **Compute statistics**: mean (μ), variance (σ²) of measured widths 4. **Assign flex parameters**: - **flexBasis**: `μ` (average measured width + padding) — can be overridden per column - **flexGrow**: derived from variance (see formula below) — can be overridden per column - **flexShrink**: derived from variance — can be overridden per column - **minWidth**: `max(μ - 2*σ, 50px)` — can be overridden per column - **maxWidth**: `300px` fixed for now **Variance-based flex formula** (normalized 0-1 scale): ```typescript const varianceScore = Math.min(1, σ / (μ * 0.5)); // 0 = no variance, 1 = high variance const flexGrow = 0.1 + varianceScore * 1.9; // 0.1 (stable) to 2.0 (variable) const flexShrink = 0.1 + varianceScore * 1.9; // same scaling ``` **User overrides**: Column definition can specify `flexGrow`, `flexShrink`, `flexBasis`, `minWidth`, `maxWidth` directly, which takes precedence over computed values. ### Dynamic Wrapping Behavior - CSS `word-break: break-word` + `white-space: normal` on cells - When column width reaches `maxWidth` (300px), text wraps to multiple lines - When table can't expand (parent has `overflow-x: hidden`), columns compress (flexShrink applies) - For custom renderer columns: uses `minWidth` from size-hinted return or default 80px User can override via column definition props. No runtime width adjustment. ## Assumptions (to validate) - pretext performance is acceptable for large datasets (~1M rows) - Custom virtualizer approach is sufficient (no TanStack Virtual needed) - Runtime height adjustment for custom renderers is acceptable UX ## Open Questions All resolved: - Fixed columns: **YES** (left/right pinning via `fixed: 'left' | 'right'`) - flexGrow/flexShrink: **Data-driven via variance**; average affects flexBasis; manual overrides allowed - maxWidth: **300px** fixed ## Requirements - **Backward compatible Props**: Keep existing prop interface (data, columns, page, height, rowKey, border, etc.) - **Config-less behavior**: Table works without any width configuration - **Content-aware column widths**: Pretext measures sampled data; variance drives flexGrow/flexShrink, mean drives flexBasis - **User overrides**: Column definition can override flexGrow, flexShrink, flexBasis, minWidth, maxWidth - **Dynamic wrapping**: maxWidth=300px triggers CSS wrapping; parent overflow-x hidden triggers compression - **Virtual scrolling**: Custom prefix-sum + binary search virtualizer; all row heights pre-computed via pretext - **Runtime height augmentation**: For custom cellRenderer columns, measure DOM after mount, maintain running average - **cellRenderer flexibility**: Return plain VNode or `{vnode, minHeight, minWidth}` object - **Pagination**: Support existing pagination UI and behavior - **Fixed columns**: Support left/right pinning via `fixed: 'left' | 'right'` - **Slots**: Support column slots and header slots - **Dict/timestamp/filesize formatting**: Keep existing built-in formatters ## Acceptance Criteria (evolving) - [ ] Existing prop interface maintained (backward compatible) - [ ] Table renders correctly without any width config - [ ] Column widths adapt to content - [ ] Long text wraps instead of overflowing - [ ] Virtual scrolling works for large datasets - [ ] Row heights estimated via pretext (no DOM measurement for height) - [ ] Pagination works - [ ] Fixed columns work - [ ] Lint/typecheck passes ## Out of Scope (explicit) - Tree data / expandable rows (not in current impl either) - Column resizing by drag (nice to have) - Sort/filter (via slot customization, not built-in) ## Technical Notes ### Implementation Structure (planned) ``` packages/base/data/list-table-v2.vue (main component) ├── usePretextColumnWidths() # pretext-based column width computation ├── usePretextRowHeights() # pretext-based row height pre-computation ├── useVirtualRows() # custom prefix-sum + binary search virtualizer └── useRuntimeHeightAugment() # ResizeObserver + running average for custom renderers ``` ### Column Width Computation (`usePretextColumnWidths`) 1. Sample data rows (up to 100 samples evenly distributed) 2. For each sampled value, measure "shrink wrap" width via pretext: ```typescript let maxW = 0; walkLineRanges(prepared, 10000, line => { if (line.width > maxW) maxW = line.width; }); // maxW = minimum width to contain all lines (widest line wins) ``` This handles embedded `\n` correctly and avoids binary search. 3. Compute mean (μ) and variance (σ²) per column 4. Derive flex parameters: - `flexBasis = μ + padding` - `flexGrow = 0.1 + (σ/μ*0.5) * 1.9` (clamped 0.1-2.0) - `flexShrink = same as flexGrow` - `minWidth = max(μ - 2*σ, 50) + padding` - `maxWidth = 300px` 5. User overrides from column definition applied after computation ### Row Height Computation (`usePretextRowHeights`) For plain text columns (no custom renderer): 1. For each row, for each column: - `prepare(text, font)` → cached - `layout(prepared, columnWidth - padding, lineHeight)` → height 2. Row height = max cell height + row padding For custom renderer columns: 1. Use `minHeight` from size-hinted return 2. After mount, measure DOM via ResizeObserver 3. Update running average per column 4. Recompute offsets when average shifts >10% ### Virtualization (`useVirtualRows`) 1. Build prefix-sum offsets: `offsets[i+1] = offsets[i] + heights[i]` 2. On scroll: binary search for `startIndex` where `offsets[i+1] >= scrollTop` 3. Linear scan to find `endIndex` where `offsets[i] < scrollTop + viewportHeight` 4. Render rows [startIndex-overscan, endIndex+overscan] with `transform: translateY(offsets[startIndex])` ### Packages Needed - `@chenglou/pretext` v0.0.4 — text measurement --- ## Implementation Plan (4 PRs) ### PR1: Scaffold Hooks & Types ✅ COMPLETED **Files created:** ``` packages/base/data/list-table-v2/ ├── index.ts # Exports all hooks and types ├── types.ts # ListTableColumn, ListTableProps, CellRendererResult, etc. ├── measureText.ts # Cached pretext measurement (prepareWithSegments caching) ├── usePretextColumnWidths.ts # Flex params from variance/mean ├── usePretextRowHeights.ts # Pre-computed row heights via pretext ├── useVirtualRows.ts # Prefix-sum + binary search virtualizer └── useRuntimeHeightAugment.ts # ResizeObserver + running average ``` **Key decisions:** - `prepareWithSegments()` for everything (superset type works with `layout()` + `walkLineRanges()`) - Single cache for PreparedTextWithSegments, LRU-capped at 10,000 entries - Types isolated in `types.ts` for backward compatibility --- ### PR2: Core Virtualizer + Pretext Measurement (PENDING) **Goal**: Wire hooks into Vue component, implement flex container, basic table shell with virtual scrolling. **Files to create/modify:** - `packages/base/data/list-table-v2/list-table-v2.vue` (rewrite) **Implementation steps:** 1. **Vue component shell** - Props: same as existing `list-table-v2.vue` (data, columns, page, height, etc.) - Emits: query, selection-change, row-click, cell-click - Use `el-auto-resizer` replacement or manual ResizeObserver for container width 2. **Wire usePretextColumnWidths** - `const { computedConfigs, totalFlexBasis } = usePretextColumnWidths(data, columns, containerWidth)` - Convert computed flex configs to CSS/flexbox styling 3. **Wire usePretextRowHeights** - `const { rowHeights, totalHeight } = usePretextRowHeights(data, columns, columnWidths)` - Returns array of heights for virtualizer 4. **Wire useVirtualRows** - `const { visibleRows, totalHeight, onScroll, scrollToIndex } = useVirtualRows(rowHeights, viewportHeight)` - Container div with `overflow-y: auto` - Inner spacer div with `height: totalHeight` - Render visible rows at `transform: translateY(offsetY)` 5. **Basic cell rendering** - Plain text cells: render formatted value - Use existing formatters (dict, timestamp, filesize) 6. **CSS flex container for columns** - Each column: `flex: ${flexGrow} ${flexShrink} ${flexBasis}px` - `min-width: ${minWidth}px` - `max-width: ${maxWidth}px` - CSS `word-break: break-word` for wrapping 7. **Header row** - Fixed height: 44px (or `headerHeight` prop if provided) - Render header cells with same flex styling **Acceptance criteria:** - [ ] Table renders without any width config - [ ] Column widths adapt to content - [ ] Scrolling works for 1000+ rows (virtualized) - [ ] No layout shift on scroll --- ### PR3: Custom cellRenderer + Runtime Augmentation (PENDING) **Goal**: Support flexible cellRenderer return types and DOM measurement for custom renderers. **Files to create/modify:** - `packages/base/data/list-table-v2/list-table-v2.vue` (update) **Implementation steps:** 1. **cellRenderer return type handling** ```typescript function resolveCellContent(cellResult: CellRendererResult, params) { if (!cellResult) return null; // Plain VNode if (cellResult.component) return cellResult; // VNode has 'component' // Size-hinted object: { vnode, minHeight, minWidth } return cellResult.vnode; } ``` 2. **Track which columns have custom renderers** - `const customRendererColumns = computed(() => columns.filter(c => c.cellRenderer))` 3. **Wire useRuntimeHeightAugment** - `const { recordHeight, flushSamples, getColumnHeight } = useRuntimeHeightAugment()` 4. **Add ResizeObserver for custom renderer rows** - For each rendered row, observe cells with custom renderers - On resize: call `recordHeight(columnKey, measuredHeight)` 5. **Recompute on significant change** - After `flushSamples()`, check if any column average shifted >10% - If so, update rowHeights and re-trigger virtualizer 6. **Initial height for custom renderers** - Check if cellRenderer returns `{vnode, minHeight}` — use that - Otherwise fall back to 44px default **Acceptance criteria:** - [ ] cellRenderer returning plain VNode works as before - [ ] cellRenderer returning `{vnode, minHeight, minWidth}` works - [ ] DOM heights measured after mount for custom renderers - [ ] Running average updates row heights dynamically --- ### PR4: Fixed Columns, Pagination, Slots, Polish (PENDING) **Goal**: Full feature parity with existing list-table-v2.vue **Files to create/modify:** - `packages/base/data/list-table-v2/list-table-v2.vue` (update) **Implementation steps:** 1. **Fixed columns (left/right pinning)** - Split columns into: left-fixed, scrollable, right-fixed - Left-fixed: `position: sticky; left: 0` - Right-fixed: `position: sticky; right: 0` - Main scroll container only contains scrollable columns - Synchronize scroll position between fixed and scrollable headers 2. **Pagination** - Add pagination div below table - Use existing `el-pagination` component - On page/size change: emit `query` 3. **Column slots** - `v-slot:[column.key]` support for custom cell rendering - `v-slot:header-[column.key]` support for custom header rendering 4. **Slots passthrough** - Pass through `slots` from parent component 5. **Built-in formatters** - dict: `formatterByDist()` from existing code - timestamp: `TzDateTime` component - filesize: `formatFileSize()` from existing code 6. **Debug mode** - Show estimated row heights, column flex params - Toggle via `debug` prop 7. **Border styling** - CSS borders on cells/rows when `border` prop is true 8. **Row/column events** - `@row-click`: emit when row div clicked - `@cell-click`: emit when cell clicked **Acceptance criteria:** - [ ] Fixed left/right columns work - [ ] Pagination emits query event - [ ] Column slots work - [ ] dict/timestamp/filesize formatting works - [ ] Debug mode shows info - [ ] Border styling works - [ ] Row/cell click events fire --- ## How to Resume After Context Clear 1. Read `prd.md` for full requirements 2. Check `task.json` for current PR status (`subtasks` array) 3. PR1 is complete — hooks are scaffolded in `packages/base/data/list-table-v2/` 4. Continue with PR2: implement `list-table-v2.vue` component 5. PR3: add cellRenderer support + runtime augmentation 6. PR4: fixed columns, pagination, slots, polish