forked from mengyxu/noob-components
3 changed files with 474 additions and 2 deletions
@ -0,0 +1,390 @@
@@ -0,0 +1,390 @@
|
||||
# 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 }) => <span>{data}</span> |
||||
``` |
||||
|
||||
**Size-hinted VNode (new):** |
||||
```typescript |
||||
cellRenderer: ({ data }) => ({ |
||||
vnode: <el-tag>{data}</el-tag>, |
||||
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<columnKey, { samples: number[], avg: number }>` |
||||
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<T>, 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 |
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
{ |
||||
"id": "list-table-v2-tanstack-pretext", |
||||
"name": "list-table-v2-tanstack-pretext", |
||||
"title": "Rebuild list-table-v2 with pretext + custom virtualizer", |
||||
"description": "Replace el-table-v2 with custom virtualized table using @chenglou/pretext for text measurement", |
||||
"status": "planning", |
||||
"dev_type": "frontend", |
||||
"scope": "packages/base/data/list-table-v2/", |
||||
"priority": "P2", |
||||
"creator": "hechang27-sprt", |
||||
"assignee": "hechang27-sprt", |
||||
"createdAt": "2026-04-03", |
||||
"completedAt": null, |
||||
"branch": null, |
||||
"base_branch": "dev", |
||||
"worktree_path": null, |
||||
"current_phase": 1, |
||||
"next_action": [ |
||||
{ |
||||
"phase": 1, |
||||
"action": "implement" |
||||
}, |
||||
{ |
||||
"phase": 2, |
||||
"action": "check" |
||||
}, |
||||
{ |
||||
"phase": 3, |
||||
"action": "finish" |
||||
}, |
||||
{ |
||||
"phase": 4, |
||||
"action": "create-pr" |
||||
} |
||||
], |
||||
"commit": null, |
||||
"pr_url": null, |
||||
"subtasks": [ |
||||
{ |
||||
"id": "pr1-scaffold", |
||||
"title": "PR1: Scaffold hooks and types", |
||||
"status": "completed", |
||||
"description": "Install @chenglou/pretext, create hooks structure, basic types" |
||||
}, |
||||
{ |
||||
"id": "pr2-core-virtualizer", |
||||
"title": "PR2: Core virtualizer + pretext measurement", |
||||
"status": "pending", |
||||
"description": "Wire hooks into Vue component, implement flex container, basic table shell with virtual scrolling" |
||||
}, |
||||
{ |
||||
"id": "pr3-custom-renderers", |
||||
"title": "PR3: Custom cellRenderer support + runtime augmentation", |
||||
"status": "pending", |
||||
"description": "Implement cellRenderer return types (plain VNode + size-hinted), ResizeObserver for custom renderers" |
||||
}, |
||||
{ |
||||
"id": "pr4-polish", |
||||
"title": "PR4: Fixed columns, pagination, slots, polish", |
||||
"status": "pending", |
||||
"description": "Fixed column pinning, pagination UI, column slots, dict/timestamp formatting, debug mode" |
||||
} |
||||
], |
||||
"children": [], |
||||
"parent": null, |
||||
"relatedFiles": [ |
||||
"packages/base/data/list-table-v2/", |
||||
"packages/base/data/list-table-v2.vue" |
||||
], |
||||
"notes": "", |
||||
"meta": { |
||||
"pr1_files": [ |
||||
"packages/base/data/list-table-v2/index.ts", |
||||
"packages/base/data/list-table-v2/types.ts", |
||||
"packages/base/data/list-table-v2/measureText.ts", |
||||
"packages/base/data/list-table-v2/usePretextColumnWidths.ts", |
||||
"packages/base/data/list-table-v2/usePretextRowHeights.ts", |
||||
"packages/base/data/list-table-v2/useVirtualRows.ts", |
||||
"packages/base/data/list-table-v2/useRuntimeHeightAugment.ts" |
||||
] |
||||
} |
||||
} |
||||
Loading…
Reference in new issue