import type { Transaction } from "@olenbetong/appframe-updater"; import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; import { addViewportEllipsis, createMessageLine, DEFAULT_TABLE_SEPARATOR, type EditorTransactionRow, formatRowLines, STATUS_DIRTY_INDICATOR, type TableColumn, } from "./tableFormatting.js"; export const CURSOR_ACTIVE = ">"; export const CURSOR_INACTIVE = " "; export const SELECTION_ACTIVE = "[x]"; export const SELECTION_INACTIVE = "[ ]"; export const PREFIX_SPACER = " "; export const CURSOR_PREFIX_WIDTH = `${CURSOR_INACTIVE}${PREFIX_SPACER}${SELECTION_INACTIVE}${PREFIX_SPACER}`.length; export const HEADER_PREFIX = " ".repeat(CURSOR_PREFIX_WIDTH); export type TransactionsViewportLine = { key: string; text: string; highlight: boolean; }; export interface VirtualScrollingStats { totalRowCount: number; displayedRowCount: number; firstVisibleRow: number; lastVisibleRow: number; } export interface UseVirtualScrollingOptions { cursor: number; setCursor: Dispatch>; dataAreaHeight: number; rows: EditorTransactionRow[]; columns: TableColumn[]; widths: number[]; tableContentWidth: number; lineWidth: number; normalizedTransactions: Transaction[]; selectedPrimKeys: Set; modifiedPrimKeys: Set; loading: boolean; initialOffset?: number; } export interface UseVirtualScrollingResult { offset: number; setOffset: Dispatch>; bodyLines: TransactionsViewportLine[]; pageStep: number; stats: VirtualScrollingStats; } export function useVirtualScrolling({ cursor, setCursor, dataAreaHeight, rows, columns, widths, tableContentWidth, lineWidth, normalizedTransactions, selectedPrimKeys, modifiedPrimKeys, loading, initialOffset = 0, }: UseVirtualScrollingOptions): UseVirtualScrollingResult { let [offset, setOffset] = useState(initialOffset); let blankRowLine = useMemo(() => " ".repeat(lineWidth), [lineWidth]); let formattedRows = useMemo(() => { return rows.map((row, index) => { let transaction = normalizedTransactions[index]; let key = transaction?.PrimKey ?? transaction?.ID ?? index; let isCursorRow = index === cursor; let isSelected = transaction ? selectedPrimKeys.has(transaction.PrimKey) : false; let isModified = transaction ? modifiedPrimKeys.has(transaction.PrimKey) : false; let printableRow = row; if (isModified) { printableRow = { ...row, Status: `${row.Status}${STATUS_DIRTY_INDICATOR}` } as EditorTransactionRow; } let contentLines = formatRowLines({ row: printableRow as EditorTransactionRow, columns, widths, separator: DEFAULT_TABLE_SEPARATOR, }); let cursorIndicator = isCursorRow ? CURSOR_ACTIVE : CURSOR_INACTIVE; let selectionIndicator = isSelected ? SELECTION_ACTIVE : SELECTION_INACTIVE; let activePrefix = `${cursorIndicator}${PREFIX_SPACER}${selectionIndicator}${PREFIX_SPACER}`; let prefixedLines = contentLines.map((line, lineIndex) => { let prefix = lineIndex === 0 ? activePrefix : HEADER_PREFIX; let padded = line.padEnd(tableContentWidth, " "); let combined = `${prefix}${padded}`; if (combined.length < lineWidth) { combined = combined.padEnd(lineWidth, " "); } else if (combined.length > lineWidth) { combined = combined.slice(0, lineWidth); } return combined; }); if (prefixedLines.length === 0) { prefixedLines = [blankRowLine]; } return { key: String(key), lines: prefixedLines, isCursor: isCursorRow, }; }); }, [ blankRowLine, columns, cursor, modifiedPrimKeys, normalizedTransactions, rows, selectedPrimKeys, tableContentWidth, widths, lineWidth, ]); let rowHeights = useMemo(() => formattedRows.map((row) => Math.max(row.lines.length, 1)), [formattedRows]); let cumulativeHeights = useMemo(() => { let values = [0]; for (let height of rowHeights) { values.push(values[values.length - 1] + height); } return values; }, [rowHeights]); let totalRowCount = formattedRows.length; let getHeightBetween = useCallback( (start: number, end: number) => { if (end < start) { return 0; } let boundedStart = Math.max(start, 0); let boundedEnd = Math.min(end, totalRowCount - 1); if (boundedEnd < boundedStart) { return 0; } return cumulativeHeights[boundedEnd + 1] - cumulativeHeights[boundedStart]; }, [cumulativeHeights, totalRowCount], ); useEffect(() => { if (totalRowCount === 0) { setCursor(0); setOffset(0); return; } setCursor((current) => Math.min(Math.max(current, 0), totalRowCount - 1)); }, [setCursor, totalRowCount]); useEffect(() => { if (totalRowCount === 0) { setOffset(0); return; } setOffset((current) => Math.min(Math.max(current, 0), totalRowCount - 1)); }, [totalRowCount]); useEffect(() => { if (dataAreaHeight <= 0) { return; } setOffset((current) => { if (totalRowCount === 0) { return 0; } let next = Math.min(Math.max(current, 0), totalRowCount - 1); if (cursor < next) { return cursor; } let height = getHeightBetween(next, cursor); while (height > dataAreaHeight && next < cursor) { next += 1; height = getHeightBetween(next, cursor); } return next; }); }, [cursor, dataAreaHeight, getHeightBetween, totalRowCount]); let displayData = useMemo(() => { let remaining = dataAreaHeight; let lines: TransactionsViewportLine[] = []; let displayedRows = 0; if (remaining <= 0) { return { lines, displayedRows }; } for (let index = offset; index < formattedRows.length && remaining > 0; index++) { let row = formattedRows[index]; let available = Math.min(row.lines.length, remaining); if (available <= 0) { break; } let truncated = row.lines.length > available; for (let lineIndex = 0; lineIndex < available; lineIndex++) { let text = row.lines[lineIndex]; if (truncated && lineIndex === available - 1) { text = addViewportEllipsis(text); } lines.push({ key: `${row.key}:${lineIndex}`, text, highlight: row.isCursor }); } remaining -= available; displayedRows += 1; if (truncated) { break; } } let fillerIndex = 0; while (remaining > 0) { lines.push({ key: `filler-${offset}-${fillerIndex}`, text: blankRowLine, highlight: false }); remaining -= 1; fillerIndex += 1; } return { lines, displayedRows }; }, [blankRowLine, dataAreaHeight, formattedRows, offset]); let bodyLines = useMemo(() => { if (dataAreaHeight <= 0) { let messageLine = createMessageLine( "Increase the terminal height to view transactions.", tableContentWidth, lineWidth, HEADER_PREFIX, ); return [{ key: "height-warning", text: messageLine, highlight: false }]; } if (rows.length === 0 && !loading) { let messageLine = createMessageLine("No transactions found.", tableContentWidth, lineWidth, HEADER_PREFIX); let fillerCount = Math.max(dataAreaHeight - 1, 0); let filler = Array.from({ length: fillerCount }, (_, index) => ({ key: `empty-filler-${index}`, text: blankRowLine, highlight: false, })); return [{ key: "empty", text: messageLine, highlight: false }, ...filler]; } if (dataAreaHeight > 0 && displayData.lines.length === 0) { return Array.from({ length: dataAreaHeight }, (_, index) => ({ key: `blank-${index}`, text: blankRowLine, highlight: false, })); } return displayData.lines; }, [blankRowLine, dataAreaHeight, displayData.lines, lineWidth, loading, rows.length, tableContentWidth]); let pageStep = Math.max(displayData.displayedRows, 1); let stats = useMemo(() => { let hasVisibleRows = totalRowCount > 0 && displayData.displayedRows > 0; let firstVisibleRow = hasVisibleRows ? Math.min(offset + 1, totalRowCount) : 0; let lastVisibleRow = hasVisibleRows ? Math.min(offset + displayData.displayedRows, totalRowCount) : 0; return { totalRowCount, displayedRowCount: displayData.displayedRows, firstVisibleRow, lastVisibleRow, }; }, [displayData.displayedRows, offset, totalRowCount]); return { offset, setOffset, bodyLines, pageStep, stats, }; }