forked from mengyxu/noob-components
15 changed files with 1127 additions and 47 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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