diff --git a/src/hooks/table/useFullHeightTable.ts b/src/hooks/table/useFullHeightTable.ts new file mode 100644 index 00000000..acb4f9fb --- /dev/null +++ b/src/hooks/table/useFullHeightTable.ts @@ -0,0 +1,298 @@ +import { useRoute } from 'vue-router'; +import { useSettingsStore } from '@/store/modules/settings'; + +interface VueBackedHTMLElement extends HTMLElement { + [key: string]: unknown; +} + +interface TableComponentHost { + proxy?: { + doLayout?: () => void; + }; +} + +interface TableState { + height: string; + maxHeight: string; + bodyHeight: string; + bodyMaxHeight: string; + bodyOverflowY: string; + bodyOverflowX: string; +} + +const tableStates = new WeakMap(); +const vueParentComponentKey = '__vueParentComponent'; + +const getTableComponentHost = (table: HTMLElement): TableComponentHost | undefined => { + const host = (table as VueBackedHTMLElement)[vueParentComponentKey]; + if (!host || typeof host !== 'object') return undefined; + return host as TableComponentHost; +}; + +const getOuterHeight = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + const marginTop = Number.parseFloat(style.marginTop) || 0; + const marginBottom = Number.parseFloat(style.marginBottom) || 0; + return element.getBoundingClientRect().height + marginTop + marginBottom; +}; + +const isVisible = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +}; + +const getFollowingHeight = (table: HTMLElement) => { + const parent = table.parentElement; + if (!parent) return 0; + + const children = Array.from(parent.children) as HTMLElement[]; + const tableIndex = children.indexOf(table); + if (tableIndex === -1) return 0; + + return children.slice(tableIndex + 1).reduce((height, child) => { + if (!isVisible(child)) return height; + return height + getOuterHeight(child); + }, 0); +}; + +const getBottomPadding = (table: HTMLElement) => { + const parent = table.parentElement; + if (!parent) return 0; + const parentStyle = window.getComputedStyle(parent); + return Number.parseFloat(parentStyle.paddingBottom) || 0; +}; + +const getBodyWrapper = (table: HTMLElement) => { + return table.querySelector('.el-table__body-wrapper'); +}; + +const getTableComponent = (table: HTMLElement) => { + return getTableComponentHost(table)?.proxy; +}; + +const getPageOverflowHeight = () => { + const documentElement = document.documentElement; + return Math.max(0, documentElement.scrollHeight - documentElement.clientHeight); +}; + +const rememberTableState = (table: HTMLElement) => { + if (tableStates.has(table)) return; + + const bodyWrapper = getBodyWrapper(table); + tableStates.set(table, { + height: table.style.height, + maxHeight: table.style.maxHeight, + bodyHeight: bodyWrapper?.style.height ?? '', + bodyMaxHeight: bodyWrapper?.style.maxHeight ?? '', + bodyOverflowY: bodyWrapper?.style.overflowY ?? '', + bodyOverflowX: bodyWrapper?.style.overflowX ?? '' + }); +}; + +const resetTable = (table: HTMLElement) => { + const originalState = tableStates.get(table); + if (!originalState) return; + + const bodyWrapper = getBodyWrapper(table); + table.classList.remove('is-full-height-table'); + table.style.height = originalState.height; + table.style.maxHeight = originalState.maxHeight; + if (bodyWrapper) { + bodyWrapper.style.height = originalState.bodyHeight; + bodyWrapper.style.maxHeight = originalState.bodyMaxHeight; + bodyWrapper.style.overflowY = originalState.bodyOverflowY; + bodyWrapper.style.overflowX = originalState.bodyOverflowX; + } + tableStates.delete(table); + getTableComponent(table)?.doLayout?.(); +}; + +const resetAllTables = () => { + document.querySelectorAll('.is-full-height-table').forEach(resetTable); +}; + +const calculateTableHeight = (table: HTMLElement) => { + const rect = table.getBoundingClientRect(); + const viewportBottom = window.innerHeight; + const followingHeight = getFollowingHeight(table); + const bottomPadding = getBottomPadding(table); + const bottomOffset = window.innerWidth < 768 ? 28 : 32; + const minHeight = window.innerWidth < 768 ? 220 : 260; + + return Math.max(minHeight, Math.floor(viewportBottom - rect.top - followingHeight - bottomPadding - bottomOffset)); +}; + +const applyTableHeightValue = (table: HTMLElement, tableHeight: number) => { + const nextHeight = `${tableHeight}px`; + if (table.style.height !== nextHeight) { + table.style.height = nextHeight; + } + if (table.style.maxHeight !== nextHeight) { + table.style.maxHeight = nextHeight; + } + table.classList.add('is-full-height-table'); + + const bodyWrapper = getBodyWrapper(table); + if (bodyWrapper) { + const headerHeight = table.querySelector('.el-table__header-wrapper')?.offsetHeight ?? 0; + const footerHeight = table.querySelector('.el-table__footer-wrapper')?.offsetHeight ?? 0; + const bodyHeight = `${Math.max(120, tableHeight - headerHeight - footerHeight)}px`; + if (bodyWrapper.style.height !== bodyHeight) { + bodyWrapper.style.height = bodyHeight; + } + if (bodyWrapper.style.maxHeight !== bodyHeight) { + bodyWrapper.style.maxHeight = bodyHeight; + } + bodyWrapper.style.overflowY = 'auto'; + bodyWrapper.style.overflowX = 'auto'; + } + + getTableComponent(table)?.doLayout?.(); +}; + +const applyTableHeight = (table: HTMLElement) => { + rememberTableState(table); + + const minHeight = window.innerWidth < 768 ? 220 : 260; + const tableHeight = calculateTableHeight(table); + applyTableHeightValue(table, tableHeight); + + const overflowHeight = getPageOverflowHeight(); + if (overflowHeight > 0 && tableHeight > minHeight) { + applyTableHeightValue(table, Math.max(minHeight, tableHeight - overflowHeight - 4)); + } +}; + +const canManageTable = (table: HTMLElement) => { + if (!isVisible(table)) return false; + if (!table.closest('.app-main')) return false; + if (!table.closest('.table-panel')) return false; + if (table.closest('.el-dialog')) return false; + if (table.dataset.fullHeightTable === 'false') return false; + + const hasManualHeight = Boolean(table.style.height || table.style.maxHeight); + return !hasManualHeight || tableStates.has(table); +}; + +const getManagedTables = () => { + return Array.from(document.querySelectorAll('.app-main .table-panel .data-table.el-table')).filter( + canManageTable + ); +}; + +export function useFullHeightTable() { + const route = useRoute(); + const settingsStore = useSettingsStore(); + const observedElements = new Set(); + let resizeObserver: ResizeObserver | undefined; + let mutationObserver: MutationObserver | undefined; + let animationFrame = 0; + const timerIds = new Set(); + + const refreshObservedElements = () => { + if (!resizeObserver) return; + + const nextElements = new Set([ + ...Array.from(document.querySelectorAll('.app-main, .app-main .p-2, .app-main .table-panel')), + ...Array.from(document.querySelectorAll('.app-main .search-panel, .app-main .tree-panel-shell')), + ...getManagedTables() + ]); + + observedElements.forEach(element => { + if (!nextElements.has(element)) { + resizeObserver?.unobserve(element); + observedElements.delete(element); + } + }); + + nextElements.forEach(element => { + if (!observedElements.has(element)) { + resizeObserver?.observe(element); + observedElements.add(element); + } + }); + }; + + const recalculate = () => { + animationFrame = 0; + if (!settingsStore.fullHeightTable) { + resetAllTables(); + return; + } + + const tables = getManagedTables(); + document.querySelectorAll('.is-full-height-table').forEach(table => { + if (!tables.includes(table)) { + resetTable(table); + } + }); + tables.forEach(applyTableHeight); + refreshObservedElements(); + }; + + const scheduleRecalculate = (delay = 0) => { + if (delay > 0) { + const timerId = window.setTimeout(() => { + timerIds.delete(timerId); + scheduleRecalculate(); + }, delay); + timerIds.add(timerId); + return; + } + if (animationFrame) return; + animationFrame = window.requestAnimationFrame(recalculate); + }; + + const scheduleLayoutRecheck = () => { + scheduleRecalculate(); + [80, 180, 360].forEach(scheduleRecalculate); + }; + + onMounted(() => { + resizeObserver = new ResizeObserver(() => scheduleLayoutRecheck()); + mutationObserver = new MutationObserver(() => { + refreshObservedElements(); + scheduleLayoutRecheck(); + }); + + const appMain = document.querySelector('.app-main'); + if (appMain) { + mutationObserver.observe(appMain, { + attributes: true, + attributeFilter: ['class', 'style'], + childList: true, + subtree: true + }); + appMain.addEventListener('transitionend', scheduleLayoutRecheck); + } + window.addEventListener('resize', scheduleLayoutRecheck); + + refreshObservedElements(); + scheduleLayoutRecheck(); + }); + + onBeforeUnmount(() => { + if (animationFrame) { + window.cancelAnimationFrame(animationFrame); + } + timerIds.forEach(timerId => window.clearTimeout(timerId)); + timerIds.clear(); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + document.querySelector('.app-main')?.removeEventListener('transitionend', scheduleLayoutRecheck); + window.removeEventListener('resize', scheduleLayoutRecheck); + resetAllTables(); + }); + + watch( + () => settingsStore.fullHeightTable, + () => scheduleLayoutRecheck() + ); + + watch( + () => route.fullPath, + () => { + nextTick(() => scheduleLayoutRecheck()); + } + ); +}