Browse Source

WIP: list-table-v2 rewrite scaffolding

dev
hechang27-sprt 3 months ago
parent
commit
1e518c0ad7
  1. 3
      bun.lock
  2. 1
      examples/App.vue
  3. 1
      examples/config/language/zh.ts
  4. 54
      examples/config/router.ts
  5. 84
      examples/view/base/pretext-demo.vue
  6. 12
      examples/view/base/table-v2.vue
  7. 4
      package.json
  8. 80
      packages/base/data/list-table-v2.vue
  9. 42
      packages/base/data/list-table-v2/index.ts
  10. 143
      packages/base/data/list-table-v2/measureText.ts
  11. 190
      packages/base/data/list-table-v2/types.ts
  12. 162
      packages/base/data/list-table-v2/usePretextColumnWidths.ts
  13. 101
      packages/base/data/list-table-v2/usePretextRowHeights.ts
  14. 149
      packages/base/data/list-table-v2/useRuntimeHeightAugment.ts
  15. 148
      packages/base/data/list-table-v2/useVirtualRows.ts

3
bun.lock

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
"": {
"name": "noob-mengyxu",
"dependencies": {
"@chenglou/pretext": "^0.0.4",
"@vueuse/core": "^14.1.0",
"axios": "^0.28.0",
"core-js": "^3.47.0",
@ -249,6 +250,8 @@ @@ -249,6 +250,8 @@
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@chenglou/pretext": ["@chenglou/pretext@0.0.4", "", {}, "sha512-FnPAFMid1/p1j2V2gRPUVBarGUIb2PhkkC9YNnTOfPtTDgHKh8siO8PP9pCxpFfYlcodWPJpE1UbSHGQqt8pQQ=="],
"@ctrl/tinycolor": ["@ctrl/tinycolor@3.6.1", "", {}, "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA=="],
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],

1
examples/App.vue

@ -44,6 +44,7 @@ const menus = [ @@ -44,6 +44,7 @@ const menus = [
{ i18n: "menu.table", path: "table", icon: "List" },
{ i18n: "menu.tableV2", path: "table-v2", icon: "List" },
{ i18n: "menu.form", path: "form", icon: "Postcard" },
{ i18n: "menu.pretextDemo", path: "pretext-demo", icon: "List" },
],
},
{

1
examples/config/language/zh.ts

@ -30,6 +30,7 @@ export default class Zh extends Lang.Zh { @@ -30,6 +30,7 @@ export default class Zh extends Lang.Zh {
base: "通用",
table: "表格",
tableV2: "表格(V2)",
pretextDemo: "Pretext Demo",
form: "表单",
tool: "工具",
terminal: "终端",

54
examples/config/router.ts

@ -1,52 +1,58 @@ @@ -1,52 +1,58 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import { Views, Common } from 'noob-mengyxu';
import Home from '../view/home.vue';
import Table from '../view/base/table.vue';
import TableV2 from '../view/base/table-v2.vue';
import Form from '../view/base/form.vue';
import Terminal from '../view/tool/terminal.vue';
import Color from '../view/tool/color.vue';
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
import { Views, Common } from "noob-mengyxu";
import Home from "../view/home.vue";
import Table from "../view/base/table.vue";
import TableV2 from "../view/base/table-v2.vue";
import Form from "../view/base/form.vue";
import Terminal from "../view/tool/terminal.vue";
import Color from "../view/tool/color.vue";
import PretextDemo from "../view/base/pretext-demo.vue";
const routes: Array<RouteRecordRaw> = [
{
path: '/',
redirect: '/home',
path: "/",
redirect: "/home",
},
{
path: '/home',
name: 'home',
path: "/home",
name: "home",
component: Home,
},
{
path: '/login',
name: 'login',
path: "/login",
name: "login",
component: Common.Login2,
},
{
path: '/table',
name: 'table',
path: "/table",
name: "table",
component: Table,
},
{
path: '/table-v2',
name: 'table-v2',
path: "/table-v2",
name: "table-v2",
component: TableV2,
},
{
path: '/form',
name: 'form',
path: "/form",
name: "form",
component: Form,
},
{
path: '/terminal',
name: 'terminal',
path: "/terminal",
name: "terminal",
component: Terminal,
},
{
path: '/color',
name: 'color',
path: "/color",
name: "color",
component: Color,
},
{
path: "/pretext-demo",
name: "pretext-demo",
component: PretextDemo,
},
];
Views.routes.forEach((item) => {

84
examples/view/base/pretext-demo.vue

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
<template>
<div class="main">
<el-button @click="generateText">Generate Lorem Ipsum</el-button>
<el-input v-model="text" type="textarea"></el-input>
<el-slider
v-model="widthPercent"
:min="0"
:max="100"
:step="1"
:format-tooltip="(percent) => `${percent}%`"
></el-slider>
<div class="comparison">
<div class="bounding-box">
<div class="render css-render">{{ text }}</div>
</div>
<div class="bounding-box">
<div class="render pretext-render" ref="pretextRenderRef">
<div v-for="(line, idx) in pretextRenderResult.lines" :key="idx">{{ line.text }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { layout, layoutWithLines, prepare, prepareWithSegments } from "@chenglou/pretext";
import { useElementSize } from "@vueuse/core";
import { LoremIpsum } from "lorem-ipsum";
import { computed, ref, StyleValue, toRef, useTemplateRef } from "vue";
const lorem = new LoremIpsum({
sentencesPerParagraph: { min: 10, max: 20 },
wordsPerSentence: { min: 4, max: 20 },
});
const text = ref<string>(lorem.generateParagraphs(1));
const widthPercent = ref<number>(60);
const width = computed(() => `${Math.round(widthPercent.value)}%`);
const generateText = () => {
text.value = lorem.generateParagraphs(1);
};
const pretextRenderRef = useTemplateRef("pretextRenderRef");
const { width: pretextRenderWidth } = useElementSize(pretextRenderRef);
const font = ref("16px Microsoft YaHei");
const lineHeight = ref(20);
const preparedText = computed(() => prepareWithSegments(text.value, font.value, { whiteSpace: "normal" }));
const pretextRenderResult = toRef(() =>
layoutWithLines(preparedText.value, pretextRenderWidth.value ?? 0, lineHeight.value)
);
</script>
<style scoped lang="scss">
.main {
flex: 1;
width: 80%;
align-self: center;
display: flex;
flex-direction: column;
}
.textarea {
min-height: 200px;
}
.comparison {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
padding: 5px;
}
.bounding-box {
flex: 1;
}
.render {
font: v-bind("font");
border: solid grey 1px;
width: v-bind("width");
height: fit-content;
line-height: v-bind("`${lineHeight}px`");
text-align: center;
}
</style>

12
examples/view/base/table-v2.vue

@ -435,7 +435,15 @@ const headerColumns = [ @@ -435,7 +435,15 @@ const headerColumns = [
</span>
</div>
<ListTableV2 :data="slotData" :columns="slotColumns" :page="false" :border="true" :row-key="'id'" height="350" debug>
<ListTableV2
:data="slotData"
:columns="slotColumns"
:page="false"
:border="true"
:row-key="'id'"
height="350"
debug
>
<template #status="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'" size="small">
{{ row.status }}
@ -917,7 +925,7 @@ const combinedExample = reactive({ page: 1, size: 10 }); @@ -917,7 +925,7 @@ const combinedExample = reactive({ page: 1, size: 10 });
const combinedData = reactive({ data: allRows.slice(0, 10), total: allRows.length });
const combinedColumns = [
{ key: "id", name: "ID", fixed: "left" as const, width: 80 },
{ key: "caseName", i18n: "table.props.0", fixed: "left" as const, minWidth: 180 },
{ key: "caseName", i18n: "table.props.0", fixed: "left" as const },
{ key: "taskName", i18n: "table.props.1" },
{ key: "userId", i18n: "table.props.2", dict: "test" as const },
{ key: "createTime", name: "Create Time", timestamp: "unix" as const, minWidth: 180 },

4
package.json

@ -86,9 +86,11 @@ @@ -86,9 +86,11 @@
"build:lib": "cross-env BUILD_LIB=true vite build",
"prepare": "npm run build:lib",
"preview": "vite preview",
"lint": "oxlint"
"lint": "oxlint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@chenglou/pretext": "^0.0.4",
"@vueuse/core": "^14.1.0",
"axios": "^0.28.0",
"core-js": "^3.47.0",

80
packages/base/data/list-table-v2.vue

@ -3,7 +3,13 @@ @@ -3,7 +3,13 @@
<!-- Mini hidden table for measuring row height and header height (only when using dynamic height mode) -->
<!-- This mirrors the real table's cell rendering for accurate height estimation -->
<!-- Hidden via display:none when off-screen to save GPU/CPU -->
<div v-if="shouldUseProbeRow" ref="miniTableRef" class="mini-table" :class="{ 'is-hidden': !isInViewport }" aria-hidden="true">
<div
v-if="shouldUseProbeRow"
ref="miniTableRef"
class="mini-table"
:class="{ 'is-hidden': !isInViewport }"
aria-hidden="true"
>
<div class="mini-table-inner">
<!-- Mini header row -->
<div ref="miniHeaderRef" class="mini-row mini-header">
@ -57,6 +63,7 @@ @@ -57,6 +63,7 @@
:estimated-row-height="resolvedRowHeight"
:border="border"
:row-key="rowKey"
:fixed="hasFixedColumns"
:class="border ? 'has-border' : ''"
@scroll="onScroll"
@row-click="onRowClick"
@ -230,6 +237,11 @@ const shouldUseProbeRow = computed(() => { @@ -230,6 +237,11 @@ const shouldUseProbeRow = computed(() => {
return prop.rowHeight === undefined && prop.estimatedRowHeight === undefined;
});
// Check if any column is fixed - used to set table's fixed prop
const hasFixedColumns = computed(() => {
return prop.columns.some((col) => col.fixed === "left" || col.fixed === "right" || col.fixed === true);
});
// The estimated row height to pass to el-table-v2
// Only used when rowHeight is NOT set (dynamic height mode)
const resolvedRowHeight = computed(() => {
@ -510,6 +522,30 @@ const onCellClick = ({ row, column }: { row: any; column: any }) => { @@ -510,6 +522,30 @@ const onCellClick = ({ row, column }: { row: any; column: any }) => {
// Build columns for el-table-v2
const tableColumns = computed(() => {
return prop.columns.map((column): Column<T> => {
// Determine if column is fixed (left or right)
const isFixed = column.fixed === "left" || column.fixed === "right" || column.fixed === true;
// Resolve minWidth to a number
const resolvedMinWidth = match(column.minWidth)
.with(P.number, (n) => n)
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => parseInt(str)
)
.otherwise(() => 120);
// Resolve explicit width if provided
const explicitWidth = match(column.width)
.with(P.number, (w) => w)
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => parseInt(str)
)
.otherwise(() => undefined);
// Build column config
const col: Column<T> = {
key: column.key,
title: column.name || (column.i18n ? t(column.i18n) : column.key),
@ -520,26 +556,28 @@ const tableColumns = computed(() => { @@ -520,26 +556,28 @@ const tableColumns = computed(() => {
.with("right", () => FixedDir.RIGHT)
.with(false, () => undefined)
.otherwise((value) => value),
minWidth: match(column.minWidth)
.with(P.number, (n) => n)
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => parseInt(str)
)
.otherwise(() => 120),
// If width is explicitly provided, use it; otherwise use flexGrow to auto-distribute
...match(column.width)
.with(P.number, (width) => ({ width }))
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => ({ width: parseInt(str) })
)
.otherwise(() => ({ width: 120, flexGrow: 1 })),
minWidth: resolvedMinWidth,
};
// Width/flexGrow logic:
// - Fixed columns: use explicit width or minWidth, NO flexGrow (el-table-v2 fixed sub-tables don't handle flexGrow)
// - Non-fixed columns with explicit width: use that width
// - Non-fixed columns without width: use width:120 with flexGrow:1 for auto-distribution
if (isFixed) {
// Fixed columns get explicit width or minWidth, no flexGrow
col.width = explicitWidth !== undefined ? explicitWidth : resolvedMinWidth;
// No flexGrow for fixed columns
} else {
// Non-fixed columns
if (explicitWidth !== undefined) {
col.width = explicitWidth;
} else {
// Auto-distribute with flexGrow
col.width = 120;
col.flexGrow = 1;
}
}
// Cell renderer - uses renderCellContent which handles slot, cellRenderer, and built-in types
col.cellRenderer = (params: CellRendererParams<T>) => renderCellContent(params, false);
@ -590,7 +628,9 @@ const renderCellContent = (params: CellRendererParams<T>, isMiniTable = false) = @@ -590,7 +628,9 @@ const renderCellContent = (params: CellRendererParams<T>, isMiniTable = false) =
// Handle dict display
if (column.dict) {
return <span class={isMiniTable ? "mini-cell-text" : "table-cell-text"}>{formatterByDist(column.dict, cellData)}</span>;
return (
<span class={isMiniTable ? "mini-cell-text" : "table-cell-text"}>{formatterByDist(column.dict, cellData)}</span>
);
}
// Handle formatting

42
packages/base/data/list-table-v2/index.ts

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
/**
* list-table-v2 - Virtualized table with pretext text measurement
*
* Hooks and utilities for the list-table-v2 component.
*/
// Types
export type {
ListTableColumn,
ListTableProps,
ListTableEmits,
PageResponse,
TzDateTimeConfig,
TimestampValue,
CellRendererResult,
SizeHintedVNode,
} from "./types";
// Text measurement
export {
measureText,
measureShrinkWrapWidth,
measureTextHeight,
clearPreparedCache,
getPreparedCacheStats,
type TextMeasurement,
} from "./measureText";
// Hooks
export { usePretextColumnWidths, type ColumnFlexConfig } from "./usePretextColumnWidths";
export { usePretextRowHeights, type RowHeightEntry } from "./usePretextRowHeights";
export {
useVirtualRows,
buildOffsets,
type VirtualRow,
type VirtualRange,
} from "./useVirtualRows";
export {
useRuntimeHeightAugment,
type HeightSample,
type ColumnHeightStats,
} from "./useRuntimeHeightAugment";

143
packages/base/data/list-table-v2/measureText.ts

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
/**
* Text measurement utilities using @chenglou/pretext
* Provides "shrink wrap" width measurement via walkLineRanges
*
* IMPORTANT: All functions cache PreparedText handles internally.
* prepareWithSegments() calls are expensive (Canvas measureText), so we cache
* by text+font key to maximize reuse.
*/
import {
prepareWithSegments,
layout,
walkLineRanges,
type PreparedTextWithSegments,
} from "@chenglou/pretext";
/**
* Cache for PreparedText handles.
* Key: `${text}|${font}`, Value: PreparedTextWithSegments (the superset type)
*/
const preparedCache = new Map<string, PreparedTextWithSegments>();
/**
* Get or create a cached PreparedText handle.
* Uses prepareWithSegments since it returns the superset type that works
* with both layout() and walkLineRanges().
*/
function getPrepared(text: string, font: string): PreparedTextWithSegments {
const cacheKey = `${text}|${font}`;
let prepared = preparedCache.get(cacheKey);
if (!prepared) {
prepared = prepareWithSegments(text, font);
preparedCache.set(cacheKey, prepared);
// Limit cache size to prevent memory leaks (simple eviction)
if (preparedCache.size > 10000) {
// Simple strategy: clear oldest half when limit reached
const keys = preparedCache.keys();
let removed = 0;
const targetRemoval = preparedCache.size / 2;
for (const key of keys) {
if (removed >= targetRemoval) break;
preparedCache.delete(key);
removed++;
}
}
}
return prepared;
}
/**
* Clear the entire prepared text cache.
* Call this if font configuration changes globally.
*/
export function clearPreparedCache() {
preparedCache.clear();
}
/**
* Get cache statistics (for debugging)
*/
export function getPreparedCacheStats() {
return {
size: preparedCache.size,
};
}
export interface TextMeasurement {
/** Minimum width to contain all lines (widest line wins) */
shrinkWrapWidth: number;
/** Height at a given maxWidth */
heightAtWidth: (maxWidth: number) => number;
/** Line count at a given maxWidth */
lineCountAtWidth: (maxWidth: number) => number;
}
/**
* Prepare text for measurement. Results are cached internally.
* @param text - The text to measure (may contain \n)
* @param font - CSS font string
*/
export function measureText(text: string, font: string): TextMeasurement {
if (!text) {
return {
shrinkWrapWidth: 0,
heightAtWidth: () => 0,
lineCountAtWidth: () => 0,
};
}
const prepared = getPrepared(text, font);
// Find shrink-wrap width: maximum line width across all lines
let shrinkWrapWidth = 0;
walkLineRanges(prepared, 10000, (line) => {
if (line.width > shrinkWrapWidth) {
shrinkWrapWidth = line.width;
}
});
return {
shrinkWrapWidth,
heightAtWidth: (maxWidth: number) => {
const result = layout(prepared, maxWidth, 20); // 20 = default lineHeight
return result.height;
},
lineCountAtWidth: (maxWidth: number) => {
const result = layout(prepared, maxWidth, 20);
return result.lineCount;
},
};
}
/**
* Measure text and get shrink-wrap width in one call.
* Uses cached PreparedText handle for performance.
*/
export function measureShrinkWrapWidth(text: string, font: string): number {
if (!text) return 0;
const prepared = getPrepared(text, font);
let maxW = 0;
walkLineRanges(prepared, 10000, (line) => {
if (line.width > maxW) maxW = line.width;
});
return maxW;
}
/**
* Measure text height at a specific column width.
* Uses cached PreparedText handle for performance.
*/
export function measureTextHeight(
text: string,
font: string,
maxWidth: number,
lineHeight: number = 20
): number {
if (!text) return 0;
const prepared = getPrepared(text, font);
const result = layout(prepared, maxWidth, lineHeight);
return result.height;
}

190
packages/base/data/list-table-v2/types.ts

@ -0,0 +1,190 @@ @@ -0,0 +1,190 @@
/**
* Type definitions for list-table-v2 component
*/
import type { CellRenderer, HeaderCellRenderer } from "element-plus/es/components/table-v2/src/types.mjs";
// =============================================================================
// Column Types
// =============================================================================
export interface ListTableColumn<T = any> {
/** Unique key for the column */
key: string;
/** Data key to extract value from row (defaults to key) */
dataKey?: string;
/** Column header text */
name?: string;
/** i18n key for header text */
i18n?: string;
/** Column type (used for formatting) */
type?: string;
/** Column width (pixel value or auto) */
width?: string | number;
/** Minimum column width */
minWidth?: string | number;
/** Maximum column width (default 300px) */
maxWidth?: string | number;
/** Flex grow factor (computed from variance if not set) */
flexGrow?: number;
/** Flex shrink factor (computed from variance if not set) */
flexShrink?: number;
/** Flex basis (computed from mean if not set) */
flexBasis?: number | string;
/** Fixed column position */
fixed?: boolean | "left" | "right";
/** Text alignment */
align?: "left" | "center" | "right";
/** Enable column slot */
slot?: boolean;
/** Dictionary key for value mapping */
dict?: string;
/** Timestamp formatting configuration */
timestamp?: TimestampValue;
/** Format as file size */
filesize?: boolean;
/** Custom cell renderer */
cellRenderer?: CellRenderer<T>;
/** Custom header renderer */
headerCellRenderer?: HeaderCellRenderer<T>;
/** Custom properties */
[key: string]: any;
}
// =============================================================================
// Timestamp Types
// =============================================================================
export type TimestampValue =
| undefined
| boolean
| string
| {
valueFormat?: string;
valueTz?: string;
displayFormat?: string;
locale?: string;
type?: "iso8601" | "unix" | "unixMillis";
};
export interface TzDateTimeConfig {
valueFormat?: string;
valueTz?: string;
displayFormat?: string;
locale?: string;
type?: "iso8601" | "unix" | "unixMillis";
}
// =============================================================================
// Table Props
// =============================================================================
export interface ListTableProps<T = any> {
/** Table data (array or page response) */
data?: T[] | PageResponse<T>;
/** Column definitions */
columns?: ListTableColumn<T>[];
/** Enable pagination */
page?: boolean;
/** Fixed table height */
height?: number | string;
/** Minimum table height */
minHeight?: number | string;
/** Maximum table height */
maxHeight?: number | string;
/** Pagination config */
example?: {
page?: number;
size?: number;
};
/** Row key field (default 'id') */
rowKey?: string;
/** Selection key */
selectKey?: string;
/** Tree data props */
treeProps?: any;
/** Lazy loading */
lazy?: boolean;
/** Show border */
border?: boolean;
/** Default timestamp format */
timestampFormat?: string;
/** Fixed row height (enables fixed-height mode) */
rowHeight?: number;
/** Header height (measured if not set) */
headerHeight?: number;
/** Debug mode */
debug?: boolean;
}
// =============================================================================
// Page Response
// =============================================================================
export interface PageResponse<T = any> {
data: T[];
total?: number;
page?: number;
size?: number;
}
// =============================================================================
// Events
// =============================================================================
export interface ListTableEmits {
(e: "query"): void;
(e: "selection-change", selection: any[]): void;
(e: "row-click", row: any): void;
(e: "cell-click", row: any, column: any, ...args: any[]): void;
}
// =============================================================================
// cellRenderer Return Types
// =============================================================================
/** Plain VNode (backward compatible) */
export type PlainVNode = ReturnType<typeof import("vue").h>;
/** Size-hinted VNode for custom renderers */
export interface SizeHintedVNode {
vnode: PlainVNode;
minHeight?: number;
minWidth?: number;
}
/** cellRenderer can return either plain VNode or size-hinted object */
export type CellRendererResult = PlainVNode | SizeHintedVNode;

162
packages/base/data/list-table-v2/usePretextColumnWidths.ts

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
/**
* usePretextColumnWidths
*
* Computes flex-based column width parameters using pretext text measurement.
* - flexBasis: mean (μ) of measured widths + padding
* - flexGrow/flexShrink: derived from variance (σ²)
* - minWidth: max(μ - 2*σ, 50) + padding
* - maxWidth: 300px
*
* User can override any parameter via column definition.
*/
import { computed, type Ref } from "vue";
import { measureShrinkWrapWidth } from "./measureText";
import type { ListTableColumn } from "./types";
const DEFAULT_FONT = "14px Inter, sans-serif";
const DEFAULT_HEADER_FONT = "bold 14px Inter, sans-serif";
const DEFAULT_PADDING = 16;
const CELL_PADDING = DEFAULT_PADDING * 2; // left + right
const MAX_WIDTH = 300;
const MIN_BASE_WIDTH = 50;
export interface ColumnFlexConfig {
key: string;
flexBasis: number;
flexGrow: number;
flexShrink: number;
minWidth: number;
maxWidth: number;
measuredMean: number;
measuredVariance: number;
measuredSampleCount: number;
}
/**
* Sample rows evenly distributed across the dataset.
* Takes first, last, and evenly spaced rows in between.
*/
function sampleRows<T>(data: T[], sampleSize: number): T[] {
if (data.length <= sampleSize) return data;
const result: T[] = [];
const step = (data.length - 1) / (sampleSize - 1);
for (let i = 0; i < sampleSize; i++) {
result.push(data[Math.round(i * step)]);
}
return result;
}
/**
* Compute mean and variance of an array of numbers
*/
function computeStats(widths: number[]): { mean: number; variance: number } {
if (widths.length === 0) return { mean: 0, variance: 0 };
const mean = widths.reduce((sum, w) => sum + w, 0) / widths.length;
const variance =
widths.reduce((sum, w) => sum + Math.pow(w - mean, 2), 0) / widths.length;
return { mean, variance };
}
/**
* Derive flexGrow/flexShrink from variance score
* varianceScore = min(1, σ / (μ * 0.5))
* flex = 0.1 + varianceScore * 1.9 range [0.1, 2.0]
*/
function varianceToFlex(mean: number, variance: number): number {
if (mean <= 0) return 0.1;
const stdDev = Math.sqrt(variance);
const varianceScore = Math.min(1, stdDev / (mean * 0.5));
return 0.1 + varianceScore * 1.9;
}
export function usePretextColumnWidths<T>(
data: Ref<T[]>,
columns: Ref<ListTableColumn<T>[]>,
containerWidth: Ref<number>,
options?: {
font?: string;
headerFont?: string;
sampleSize?: number;
}
) {
const font = options?.font ?? DEFAULT_FONT;
const headerFont = options?.headerFont ?? DEFAULT_HEADER_FONT;
const sampleSize = options?.sampleSize ?? 100;
const computedConfigs = computed<ColumnFlexConfig[]>(() => {
if (!columns.value.length) return [];
const sampled = sampleRows(data.value, sampleSize);
const configs: ColumnFlexConfig[] = [];
for (const col of columns.value) {
const colKey = String(col.key);
// User overrides (convert string values to numbers)
const userFlexGrow = col.flexGrow;
const userFlexShrink = col.flexShrink;
const userFlexBasis =
col.flexBasis !== undefined && col.flexBasis !== "auto"
? Number(col.flexBasis)
: undefined;
const userMinWidth =
col.minWidth !== undefined ? Number(col.minWidth) : undefined;
const userMaxWidth =
col.maxWidth !== undefined ? Number(col.maxWidth) : undefined;
// Measure header width
const headerText = col.name || col.i18n || colKey;
const headerWidth = measureShrinkWrapWidth(headerText, headerFont) + CELL_PADDING;
// Measure sampled cell widths
const cellWidths: number[] = [];
for (const row of sampled) {
const rawValue = (row as any)[col.dataKey || colKey];
const cellText = rawValue == null ? "" : String(rawValue);
if (cellText) {
const w = measureShrinkWrapWidth(cellText, font) + CELL_PADDING;
cellWidths.push(w);
}
}
// Include header in stats
const allWidths = [headerWidth, ...cellWidths];
const { mean, variance } = computeStats(allWidths);
// Derived values
const flexBasis = userFlexBasis ?? mean;
const minWidth = userMinWidth
? Number(userMinWidth)
: Math.max(mean - 2 * Math.sqrt(variance), MIN_BASE_WIDTH) + CELL_PADDING;
const maxWidth = userMaxWidth ? Number(userMaxWidth) : MAX_WIDTH;
const flexGrow = userFlexGrow ?? varianceToFlex(mean, variance);
const flexShrink = userFlexShrink ?? varianceToFlex(mean, variance);
configs.push({
key: colKey,
flexBasis,
flexGrow,
flexShrink,
minWidth,
maxWidth,
measuredMean: mean,
measuredVariance: variance,
measuredSampleCount: allWidths.length,
});
}
return configs;
});
// Total flex basis (sum of all flexBasis values)
const totalFlexBasis = computed(() =>
computedConfigs.value.reduce((sum, c) => sum + c.flexBasis, 0)
);
return {
computedConfigs,
totalFlexBasis,
};
}

101
packages/base/data/list-table-v2/usePretextRowHeights.ts

@ -0,0 +1,101 @@ @@ -0,0 +1,101 @@
/**
* usePretextRowHeights
*
* Pre-computes row heights using pretext text measurement.
* For each row, measures each cell's height at the given column width,
* then takes the maximum + padding as the row height.
*/
import { computed, type Ref } from "vue";
import { measureTextHeight } from "./measureText";
import type { ListTableColumn } from "./types";
const DEFAULT_FONT = "14px Inter, sans-serif";
const DEFAULT_LINE_HEIGHT = 20;
const DEFAULT_ROW_PADDING = 12;
const CELL_VERTICAL_PADDING = 8; // top + bottom per cell
export interface RowHeightEntry {
height: number;
isCustomRenderer: boolean;
}
export function usePretextRowHeights<T>(
data: Ref<T[]>,
columns: Ref<ListTableColumn<T>[]>,
columnWidths: Ref<number[]>,
options?: {
font?: string;
lineHeight?: number;
rowPadding?: number;
}
) {
const font = options?.font ?? DEFAULT_FONT;
const lineHeight = options?.lineHeight ?? DEFAULT_LINE_HEIGHT;
const rowPadding = options?.rowPadding ?? DEFAULT_ROW_PADDING;
const rowHeights = computed<RowHeightEntry[]>(() => {
if (!data.value.length || !columns.value.length || !columnWidths.value.length) {
return [];
}
return data.value.map((row) => {
let maxCellHeight = lineHeight; // minimum 1 line
for (let i = 0; i < columns.value.length; i++) {
const col = columns.value[i];
const colWidth = columnWidths.value[i] ?? 100;
// Check if custom renderer exists (we can't measure these with pretext)
const hasCustomRenderer = !!(col.cellRenderer || col.slot);
if (hasCustomRenderer) {
// For custom renderers, we use a placeholder height
// Actual height will be measured at runtime via useRuntimeHeightAugment
// For now, use a reasonable minimum
const placeholderHeight = 44; // default row height
maxCellHeight = Math.max(maxCellHeight, placeholderHeight);
continue;
}
// Get raw cell value
const rawValue = (row as any)[col.dataKey || col.key];
const cellText = rawValue == null ? "" : String(rawValue);
if (!cellText) continue;
// Calculate available width for text (excluding cell padding)
const availableWidth = colWidth - CELL_VERTICAL_PADDING * 2;
if (availableWidth <= 0) continue;
try {
const cellHeight = measureTextHeight(
cellText,
font,
availableWidth,
lineHeight
);
maxCellHeight = Math.max(maxCellHeight, cellHeight);
} catch {
// Fallback: assume single line
}
}
const totalHeight = maxCellHeight + rowPadding * 2 + CELL_VERTICAL_PADDING * 2;
return {
height: totalHeight,
isCustomRenderer: false,
};
});
});
// Total height (sum of all row heights) - useful for virtualizer
const totalHeight = computed(() =>
rowHeights.value.reduce((sum, entry) => sum + entry.height, 0)
);
return {
rowHeights,
totalHeight,
};
}

149
packages/base/data/list-table-v2/useRuntimeHeightAugment.ts

@ -0,0 +1,149 @@ @@ -0,0 +1,149 @@
/**
* useRuntimeHeightAugment
*
* For columns with custom cellRenderer that return {vnode, minHeight, minWidth},
* we need to measure actual DOM height after render and maintain a running
* average per column to self-adjust row heights.
*/
import { ref, reactive, type Ref } from "vue";
const SAMPLE_THRESHOLD = 5; // Minimum samples before updating average
const RECOMPUTE_THRESHOLD = 0.1; // 10% shift triggers recompute
export interface HeightSample {
columnKey: string;
height: number;
timestamp: number;
}
export interface ColumnHeightStats {
columnKey: string;
samples: number[];
average: number;
count: number;
}
export function useRuntimeHeightAugment() {
// Map from columnKey -> stats
const columnStats = reactive<Map<string, ColumnHeightStats>>(new Map());
// Pending samples not yet incorporated
const pendingSamples = ref<HeightSample[]>([]);
/**
* Record a measured height for a specific column's cell
*/
function recordHeight(columnKey: string, height: number) {
pendingSamples.value.push({
columnKey,
height,
timestamp: Date.now(),
});
}
/**
* Flush pending samples and update running averages
* Returns true if any column's average changed significantly
*/
function flushSamples(): boolean {
if (pendingSamples.value.length === 0) return false;
const changed: string[] = [];
// Group samples by column
const grouped = new Map<string, number[]>();
for (const sample of pendingSamples.value) {
const existing = grouped.get(sample.columnKey) || [];
existing.push(sample.height);
grouped.set(sample.columnKey, existing);
}
// Update stats for each column
for (const [columnKey, heights] of grouped) {
let stats = columnStats.get(columnKey);
if (!stats) {
stats = {
columnKey,
samples: [],
average: 0,
count: 0,
};
columnStats.set(columnKey, stats);
}
// Add new samples
stats.samples.push(...heights);
stats.count += heights.length;
// Keep only last 20 samples per column for running average
if (stats.samples.length > 20) {
stats.samples = stats.samples.slice(-20);
}
// Recompute average
const newAverage =
stats.samples.reduce((sum, h) => sum + h, 0) / stats.samples.length;
// Check if change is significant
if (stats.count >= SAMPLE_THRESHOLD) {
const oldAverage = stats.average;
if (oldAverage > 0 && Math.abs(newAverage - oldAverage) / oldAverage > RECOMPUTE_THRESHOLD) {
changed.push(columnKey);
}
}
stats.average = newAverage;
}
// Clear pending
pendingSamples.value = [];
return changed.length > 0;
}
/**
* Get the current estimated height for a column
*/
function getColumnHeight(columnKey: string): number {
const stats = columnStats.get(columnKey);
return stats?.average ?? 44; // Default fallback
}
/**
* Get all column heights as a map
*/
function getAllColumnHeights(): Map<string, number> {
const result = new Map<string, number>();
for (const [key, stats] of columnStats) {
result.set(key, stats.average || 44);
}
return result;
}
/**
* Reset stats for a column
*/
function resetColumn(columnKey: string) {
columnStats.delete(columnKey);
}
/**
* Reset all stats
*/
function resetAll() {
columnStats.clear();
pendingSamples.value = [];
}
return {
columnStats,
pendingSamples,
recordHeight,
flushSamples,
getColumnHeight,
getAllColumnHeights,
resetColumn,
resetAll,
};
}

148
packages/base/data/list-table-v2/useVirtualRows.ts

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
/**
* useVirtualRows
*
* Custom virtualizer using prefix-sum offsets + binary search.
* Inspired by pretext-table approach - lightweight, works perfectly
* with pre-computed row heights from pretext.
*/
import { ref, computed, watch, type Ref } from "vue";
const DEFAULT_OVERSCAN = 5;
export interface VirtualRow {
index: number;
offsetY: number;
height: number;
}
export interface VirtualRange {
startIndex: number;
endIndex: number;
offsetY: number;
}
export interface UseVirtualRowsOptions {
overscan?: number;
}
/**
* Build a prefix-sum array from row heights for O(1) offset lookups.
*/
export function buildOffsets(heights: number[]): number[] {
const offsets = new Array(heights.length + 1);
offsets[0] = 0;
for (let i = 0; i < heights.length; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
return offsets;
}
/**
* Binary search: find the first row index where bottom edge >= scrollTop.
* Returns index in range [0, heights.length - 1].
*/
function findStartIndex(offsets: number[], scrollTop: number): number {
let lo = 0;
let hi = offsets.length - 2; // last valid row index
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= scrollTop) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export function useVirtualRows(
rowHeights: Ref<number[]>,
viewportHeight: Ref<number>,
options?: UseVirtualRowsOptions
) {
const overscan = options?.overscan ?? DEFAULT_OVERSCAN;
const scrollTop = ref(0);
// Build prefix-sum offsets whenever rowHeights changes
const offsets = computed(() => buildOffsets(rowHeights.value));
// Total scrollable height
const totalHeight = computed(() => {
const last = offsets.value[offsets.value.length - 1];
return last ?? 0;
});
// Compute visible range
const range = computed<VirtualRange>(() => {
if (rowHeights.value.length === 0) {
return { startIndex: 0, endIndex: 0, offsetY: 0 };
}
const st = scrollTop.value;
const vp = viewportHeight.value;
const rawStart = findStartIndex(offsets.value, st);
const startIndex = Math.max(0, rawStart - overscan);
// Find end index: first row whose top is past scrollTop + viewportHeight
let endIndex = rawStart;
while (
endIndex < rowHeights.value.length &&
offsets.value[endIndex] < st + vp
) {
endIndex++;
}
endIndex = Math.min(rowHeights.value.length, endIndex + overscan);
return {
startIndex,
endIndex,
offsetY: offsets.value[startIndex],
};
});
// Get visible rows with their positions
const visibleRows = computed<VirtualRow[]>(() => {
const { startIndex, endIndex, offsetY } = range.value;
const rows: VirtualRow[] = [];
for (let i = startIndex; i < endIndex; i++) {
rows.push({
index: i,
offsetY: offsets.value[i],
height: rowHeights.value[i],
});
}
return rows;
});
// Scroll handler to be attached to the scroll container
const onScroll = (newScrollTop: number) => {
scrollTop.value = newScrollTop;
};
// Scroll to a specific row index
const scrollToIndex = (index: number) => {
if (index < 0 || index >= offsets.value.length) return;
scrollTop.value = offsets.value[index];
};
// Scroll to a specific position
const scrollTo = (position: number) => {
scrollTop.value = Math.max(0, position);
};
return {
scrollTop,
totalHeight,
range,
visibleRows,
onScroll,
scrollToIndex,
scrollTo,
offsets,
};
}
Loading…
Cancel
Save