diff --git a/bun.lock b/bun.lock index 428ab10..02c7791 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "xterm": "^5.3.0", "xterm-addon-attach": "^0.9.0", "xterm-addon-fit": "^0.8.0", + "yoga-layout": "^3.2.1", }, "devDependencies": { "@types/js-md5": "^0.8.0", @@ -1888,6 +1889,8 @@ "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@microsoft/api-extractor/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], diff --git a/package.json b/package.json index 703ec2a..d3eb61e 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "vuex": "^4.1.0", "xterm": "^5.3.0", "xterm-addon-attach": "^0.9.0", - "xterm-addon-fit": "^0.8.0" + "xterm-addon-fit": "^0.8.0", + "yoga-layout": "^3.2.1" }, "devDependencies": { "@types/js-md5": "^0.8.0", diff --git a/packages/base/data/list-table-v2/index.ts b/packages/base/data/list-table-v2/index.ts index 61b59aa..14f3f8e 100644 --- a/packages/base/data/list-table-v2/index.ts +++ b/packages/base/data/list-table-v2/index.ts @@ -27,7 +27,7 @@ export { } from "./measureText"; // Hooks -export { usePretextColumnWidths, type ColumnFlexConfig } from "./usePretextColumnWidths"; +export { usePretextColumnWidths, computeFlexWidths, type ColumnFlexConfig } from "./usePretextColumnWidths"; export { usePretextRowHeights, type RowHeightEntry } from "./usePretextRowHeights"; export { useVirtualRows, diff --git a/packages/base/data/list-table-v2/list-table-v2.vue b/packages/base/data/list-table-v2/list-table-v2.vue index dde0152..62a9087 100644 --- a/packages/base/data/list-table-v2/list-table-v2.vue +++ b/packages/base/data/list-table-v2/list-table-v2.vue @@ -4,8 +4,17 @@
Container Width: {{ containerWidth }}
Total Height: {{ virtualTotalHeight }}
-
Visible Rows: {{ visibleRows.length }}
+
Visible Rows: {{ visibleRows.map((entry) => entry.height).join(",") }}
+
+ Cell Heights: + {{ + rowHeights.map((entry) => + entry.cellHeights?.map((entry) => `${entry.isCustomRenderer ? "*" : ""}${entry.height}`).join(",") + ) + }} +
Total Rows: {{ pageData.length }}
+
Column widths: {{ columnWidths.map((w) => Math.round(w)).join(",") }}
Column configs: {{ @@ -86,6 +95,8 @@ import { usePretextColumnWidths, type ColumnFlexConfig } from "./usePretextColum import { usePretextRowHeights, type RowHeightEntry } from "./usePretextRowHeights"; import { useVirtualRows } from "./useVirtualRows"; +const DEFAULT_FONT = "14px sans-serif"; + const slots = useSlots(); const { t } = useI18n(); const { state } = useStore(); @@ -119,6 +130,7 @@ interface Props { rowHeight?: number; estimatedRowHeight?: number; headerHeight?: number; + font?: string; debug?: boolean; } @@ -139,6 +151,7 @@ const props = withDefaults(defineProps(), { rowHeight: undefined, estimatedRowHeight: undefined, headerHeight: 44, + font: DEFAULT_FONT, debug: false, }); @@ -173,26 +186,88 @@ const columnsRef = toRef(props, "columns"); // Pass computed pageData to hooks const pageDataRef = toRef(pageData); +// ============================================================================= +// Cell formatting +// ============================================================================= +const getHeaderText = (col: ListTableColumn): string => { + if (col.name) return col.name; + if (col.i18n) return t(col.i18n); + return col.key; +}; + +const formatCellValue = (row: T, col: ListTableColumn): string => { + const value = (row as any)[col.dataKey || col.key]; + + if (col.dict) { + return formatterByDist(col.dict, value); + } + if (col.timestamp) { + return formatStamp(value); + } + if (col.filesize) { + return formatFileSize(value); + } + + if (value === undefined || value === null || value === "") { + return "--"; + } + return String(value); +}; + +const getValue = (value: any): string => { + if ((typeof value === "undefined" || value === null || value === "") && value !== 0) { + return "--"; + } + return value; +}; + +const formatStamp = (value: any): string => { + const date = new Date(value * 1000); + const month = date.getMonth() < 9 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1; + const day = date.getDate() < 10 ? "0" + date.getDate() : date.getDate(); + const hour = date.getHours() < 10 ? "0" + date.getHours() : date.getHours(); + const minute = date.getMinutes() < 10 ? "0" + date.getMinutes() : date.getMinutes(); + const second = date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds(); + return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`; +}; + +const formatFileSize = (value: any): string => { + const k = value / 1024; + if (k < 1) return value + "B"; + const m = k / 1024; + if (m < 1) return k.toFixed(2) + "K"; + const g = m / 1024; + if (g < 1) return m.toFixed(2) + "M"; + return g.toFixed(2) + "G"; +}; + +const formatterByDist = (dictKey: string, cellData: any): string => { + if (!dictKey) return getValue(cellData); + const mapping = (state as any).dict[dictKey]; + if (mapping == null) return getValue(cellData); + return mapping[cellData] == null ? cellData : mapping[cellData]; +}; + // ============================================================================= // Wire hooks // ============================================================================= // Column width computation via pretext -const { computedConfigs, totalFlexBasis } = usePretextColumnWidths(pageDataRef, columnsRef, containerWidth, { - font: "14px Inter, sans-serif", - headerFont: "bold 14px Inter, sans-serif", -}); - -// Column widths as array of numbers (for usePretextRowHeights) -const columnWidths = computed(() => { - return computedConfigs.value.map((c) => c.flexBasis); -}); +const { computedConfigs, totalFlexBasis, columnWidths } = usePretextColumnWidths( + pageDataRef, + columnsRef, + containerWidth, + { + font: "14px Inter, sans-serif", + headerFont: "bold 14px Inter, sans-serif", + } +); -// Row heights via pretext -const { rowHeights, totalHeight } = usePretextRowHeights(pageDataRef, columnsRef, columnWidths, { - font: "14px Inter, sans-serif", +// Row heights via pretext - use actual column widths from flexbox algorithm +const { rowHeights, totalHeight } = usePretextRowHeights(pageDataRef, columnsRef, columnWidths, formatCellValue, { + font: props.font, lineHeight: 20, - rowPadding: 12, + debug: props.debug, }); // Virtualizer (needs rowHeights as number[] and viewportHeight) @@ -237,14 +312,28 @@ const tableContainerStyle = computed(() => { // Column styling // ============================================================================= function getColumnStyle(col: ListTableColumn): Record { - const config = computedConfigs.value.find((c) => c.key === col.key); - if (!config) { - return { flex: "1 1 100px", minWidth: "50px", maxWidth: "300px" }; + // Find the column index to get the computed width + const colIndex = columnsRef.value.findIndex((c) => c.key === col.key); + const computedWidth = colIndex >= 0 ? columnWidths.value[colIndex] : 0; + + if (!computedWidth || computedWidth <= 0) { + // Fallback to flex property if no computed width + const config = computedConfigs.value.find((c) => c.key === col.key); + if (!config) { + return { flex: "1 1 100px", minWidth: "50px", maxWidth: "300px" }; + } + return { + flex: `${config.flexGrow} ${config.flexShrink} ${config.flexBasis}px`, + minWidth: `${config.minWidth}px`, + maxWidth: `${config.maxWidth}px`, + }; } + + // Use the yoga-computed width directly for consistent measurement return { - flex: `${config.flexGrow} ${config.flexShrink} ${config.flexBasis}px`, - minWidth: `${config.minWidth}px`, - maxWidth: `${config.maxWidth}px`, + width: `${computedWidth}px`, + minWidth: `${computedWidth}px`, + maxWidth: `${computedWidth}px`, }; } @@ -255,68 +344,6 @@ const resolvedHeaderHeight = computed(() => { return props.headerHeight ?? 44; }); -// ============================================================================= -// Cell formatting -// ============================================================================= -const getHeaderText = (col: ListTableColumn): string => { - if (col.name) return col.name; - if (col.i18n) return t(col.i18n); - return col.key; -}; - -const formatCellValue = (row: T, col: ListTableColumn): string => { - const value = (row as any)[col.dataKey || col.key]; - - if (col.dict) { - return formatterByDist(col.dict, value); - } - if (col.timestamp) { - return formatStamp(value); - } - if (col.filesize) { - return formatFileSize(value); - } - - if (value === undefined || value === null || value === "") { - return "--"; - } - return String(value); -}; - -const getValue = (value: any): string => { - if ((typeof value === "undefined" || value === null || value === "") && value !== 0) { - return "--"; - } - return value; -}; - -const formatStamp = (value: any): string => { - const date = new Date(value * 1000); - const month = date.getMonth() < 9 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1; - const day = date.getDate() < 10 ? "0" + date.getDate() : date.getDate(); - const hour = date.getHours() < 10 ? "0" + date.getHours() : date.getHours(); - const minute = date.getMinutes() < 10 ? "0" + date.getMinutes() : date.getMinutes(); - const second = date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds(); - return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`; -}; - -const formatFileSize = (value: any): string => { - const k = value / 1024; - if (k < 1) return value + "B"; - const m = k / 1024; - if (m < 1) return k.toFixed(2) + "K"; - const g = m / 1024; - if (g < 1) return m.toFixed(2) + "M"; - return g.toFixed(2) + "G"; -}; - -const formatterByDist = (dictKey: string, cellData: any): string => { - if (!dictKey) return getValue(cellData); - const mapping = (state as any).dict[dictKey]; - if (mapping == null) return getValue(cellData); - return mapping[cellData] == null ? cellData : mapping[cellData]; -}; - // ============================================================================= // Scroll handling // ============================================================================= @@ -499,7 +526,7 @@ onUnmounted(() => { white-space: normal; word-break: break-word; color: v-bind("state.style.tableColor"); - font-size: var(--el-font-size-base); + font: v-bind("props.font"); } .my-pagination { diff --git a/packages/base/data/list-table-v2/usePretextColumnWidths.ts b/packages/base/data/list-table-v2/usePretextColumnWidths.ts index 308903a..75ef799 100644 --- a/packages/base/data/list-table-v2/usePretextColumnWidths.ts +++ b/packages/base/data/list-table-v2/usePretextColumnWidths.ts @@ -53,8 +53,7 @@ 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; + const variance = widths.reduce((sum, w) => sum + Math.pow(w - mean, 2), 0) / widths.length; return { mean, variance }; } @@ -71,6 +70,118 @@ function varianceToFlex(mean: number, variance: number): number { return 0.1 + varianceScore * 1.9; } +/** + * Compute actual column widths using a flexbox-inspired distribution algorithm. + * - First, compute raw flex widths based on available space + * - Then, enforce minWidth/maxWidth constraints iteratively + * - If constraints cause overflow, reduce widths proportionally while respecting mins + */ +export function computeFlexWidths(configs: ColumnFlexConfig[], containerWidth: number): number[] { + if (!configs.length || containerWidth <= 0) { + return configs.map(() => 0); + } + + // Calculate total flex basis + const totalFlexBasis = configs.reduce((sum, c) => sum + c.flexBasis, 0); + + if (totalFlexBasis === 0) { + // Edge case: all zero bases - distribute evenly + const evenWidth = containerWidth / configs.length; + return configs.map((c) => clamp(evenWidth, c.minWidth, c.maxWidth)); + } + + // Calculate raw flex widths + let widths = computeRawFlexWidths(configs, containerWidth, totalFlexBasis); + + // Apply min/max constraints iteratively until stable + // This handles the case where clamping some columns causes others to overflow + for (let iteration = 0; iteration < 10; iteration++) { + // Apply constraints + widths = widths.map((w, i) => clamp(w, configs[i].minWidth, configs[i].maxWidth)); + + const totalWidth = widths.reduce((sum, w) => sum + w, 0); + + if (totalWidth <= containerWidth) { + // Fits! Distribute remaining space proportionally based on flexGrow + const remaining = containerWidth - totalWidth; + if (remaining > 0) { + const totalFlexGrow = configs.reduce((sum, c) => sum + c.flexGrow * c.flexBasis, 0); + if (totalFlexGrow > 0) { + widths = widths.map((w, i) => { + const growAmount = (configs[i].flexGrow * configs[i].flexBasis) / totalFlexGrow * remaining; + return w + growAmount; + }); + } + } + break; + } + + // Overflow - need to reduce widths that are above their minimums + const overflow = totalWidth - containerWidth; + + // Calculate how much each column can be reduced (only those above minWidth) + const reducible = widths.map((w, i) => Math.max(0, w - configs[i].minWidth)); + const totalReducible = reducible.reduce((sum, r) => sum + r, 0); + + if (totalReducible === 0) { + // All at minimum - force even distribution + const evenWidth = containerWidth / configs.length; + widths = configs.map((c) => clamp(evenWidth, c.minWidth, c.maxWidth)); + break; + } + + // Reduce proportionally based on how much each column can be reduced + widths = widths.map((w, i) => { + const reduction = reducible[i] / totalReducible * overflow; + return Math.max(w - reduction, configs[i].minWidth); + }); + } + + // Final clamp + widths = widths.map((w, i) => clamp(w, configs[i].minWidth, configs[i].maxWidth)); + + return widths; +} + +/** + * Compute raw flex widths without min/max constraints + */ +function computeRawFlexWidths(configs: ColumnFlexConfig[], containerWidth: number, totalFlexBasis: number): number[] { + const availableSpace = containerWidth - totalFlexBasis; + + if (availableSpace === 0) { + return configs.map((c) => c.flexBasis); + } + + if (availableSpace > 0) { + // Grow phase: distribute extra space proportionally by flexGrow * flexBasis + const totalGrowFactor = configs.reduce((sum, c) => sum + c.flexGrow * c.flexBasis, 0); + if (totalGrowFactor === 0) { + return configs.map((c) => c.flexBasis); + } + return configs.map((c) => { + const growAmount = (c.flexGrow * c.flexBasis) / totalGrowFactor * availableSpace; + return c.flexBasis + growAmount; + }); + } else { + // Shrink phase: remove space proportionally by flexShrink * flexBasis + const totalShrinkFactor = configs.reduce((sum, c) => sum + c.flexShrink * c.flexBasis, 0); + if (totalShrinkFactor === 0) { + // No shrink - distribute loss evenly + const evenShrink = (-availableSpace) / configs.length; + return configs.map((c) => Math.max(0, c.flexBasis - evenShrink)); + } + return configs.map((c) => { + const shrinkAmount = (c.flexShrink * c.flexBasis) / totalShrinkFactor * (-availableSpace); + return Math.max(0, c.flexBasis - shrinkAmount); + }); + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + export function usePretextColumnWidths( data: Ref, columns: Ref[]>, @@ -97,14 +208,9 @@ export function usePretextColumnWidths( // 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; + 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; @@ -151,12 +257,14 @@ export function usePretextColumnWidths( }); // Total flex basis (sum of all flexBasis values) - const totalFlexBasis = computed(() => - computedConfigs.value.reduce((sum, c) => sum + c.flexBasis, 0) - ); + const totalFlexBasis = computed(() => computedConfigs.value.reduce((sum, c) => sum + c.flexBasis, 0)); + + // Actual column widths computed using flexbox algorithm + const columnWidths = computed(() => computeFlexWidths(computedConfigs.value, containerWidth.value)); return { computedConfigs, totalFlexBasis, + columnWidths, }; } diff --git a/packages/base/data/list-table-v2/usePretextRowHeights.ts b/packages/base/data/list-table-v2/usePretextRowHeights.ts index 314ba55..442b5cc 100644 --- a/packages/base/data/list-table-v2/usePretextRowHeights.ts +++ b/packages/base/data/list-table-v2/usePretextRowHeights.ts @@ -11,22 +11,25 @@ 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 +const DEFAULT_ROW_PADDING = 5; +const CELL_VERTICAL_PADDING = 12; // top + bottom per cell export interface RowHeightEntry { height: number; isCustomRenderer: boolean; + cellHeights?: RowHeightEntry[]; } export function usePretextRowHeights( data: Ref, columns: Ref[]>, columnWidths: Ref, + formatCellValue: (row: T, col: ListTableColumn) => string, options?: { font?: string; lineHeight?: number; rowPadding?: number; + debug?: boolean; } ) { const font = options?.font ?? DEFAULT_FONT; @@ -40,9 +43,12 @@ export function usePretextRowHeights( return data.value.map((row) => { let maxCellHeight = lineHeight; // minimum 1 line + let cellHeights: RowHeightEntry[] | undefined = options?.debug ? [] : undefined; for (let i = 0; i < columns.value.length; i++) { const col = columns.value[i]; + // Use flex basis width for height calculation as an approximation + // of actual column width (CSS flex layout determines actual width) const colWidth = columnWidths.value[i] ?? 100; // Check if custom renderer exists (we can't measure these with pretext) @@ -54,13 +60,16 @@ export function usePretextRowHeights( // For now, use a reasonable minimum const placeholderHeight = 44; // default row height maxCellHeight = Math.max(maxCellHeight, placeholderHeight); + + cellHeights?.push({ + height: placeholderHeight, + isCustomRenderer: true, + }); continue; } // Get raw cell value - const rawValue = (row as any)[col.dataKey || col.key]; - const cellText = rawValue == null ? "" : String(rawValue); - + const cellText = formatCellValue(row, col); if (!cellText) continue; // Calculate available width for text (excluding cell padding) @@ -68,31 +77,30 @@ export function usePretextRowHeights( if (availableWidth <= 0) continue; try { - const cellHeight = measureTextHeight( - cellText, - font, - availableWidth, - lineHeight - ); + const cellHeight = measureTextHeight(cellText, font, availableWidth, lineHeight); maxCellHeight = Math.max(maxCellHeight, cellHeight); + + cellHeights?.push({ + height: cellHeight, + isCustomRenderer: false, + }); } catch { // Fallback: assume single line } } - const totalHeight = maxCellHeight + rowPadding * 2 + CELL_VERTICAL_PADDING * 2; + const totalHeight = maxCellHeight + rowPadding * 2; return { height: totalHeight, isCustomRenderer: false, + cellHeights: cellHeights ?? undefined, }; }); }); // Total height (sum of all row heights) - useful for virtualizer - const totalHeight = computed(() => - rowHeights.value.reduce((sum, entry) => sum + entry.height, 0) - ); + const totalHeight = computed(() => rowHeights.value.reduce((sum, entry) => sum + entry.height, 0)); return { rowHeights,