forked from mengyxu/noob-components
15 changed files with 1127 additions and 47 deletions
@ -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> |
||||
@ -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"; |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
@ -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, |
||||
}; |
||||
} |
||||
@ -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, |
||||
}; |
||||
} |
||||
@ -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, |
||||
}; |
||||
} |
||||
@ -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…
Reference in new issue