import cx from 'classnames' import React, { Fragment, } from 'react' import type { CSSProperties, ReactNode, } from 'react' import { BehaviorSubject, combineLatest, noop, Subscription } from 'rxjs' import * as op from 'rxjs/operators' import type { ArtColumn } from '../interfaces' import { calculateRenderInfo } from './calculations' import { EmptyHtmlTable } from './empty' import TableHeader from './header' import { getRichVisibleRectsStream, fromResizeEvent } from './helpers/getRichVisibleRectsStream' import { getFullRenderRange, makeRowHeightManager } from './helpers/rowHeightManager' import { TableDOMHelper } from './helpers/TableDOMUtils' import { HtmlTable } from './html-table' import { RenderInfo, ResolvedUseVirtual, VerticalRenderRange, VirtualEnum } from './interfaces' import Loading from './loading' import type { LoadingContentWrapperProps } from './loading' import { Classes, StyleArtTableHeaderTopLayout, StyleArtTableHeaderTopLayoutLeft, StyleArtTableHeaderTopLayoutMiddle, StyleArtTableHeaderTopLayoutRight, StyledArtTableWrapper } from './styles' import type { BaseTableCSSVariables, } from "./styles" import { getScrollbarSize, OVERSCAN_SIZE, shallowEqual, STYLED_REF_PROP, sum, syncScrollLeft, throttledWindowResize$, } from './utils' import { BaseTableContext, useBaseTableInstance, BaseTableInstance, } from "../pipeline/instance" import { fromEvent } from 'rxjs' import { DragInstanceProvider, useDragInstance, DragInstance } from "../pipeline/dragInstance" import type { OnUpdatedOptions } from "../pipeline/dragInstance" import { PlaceArea } from './place-area' let emptyContentDeprecatedWarned = false function warnEmptyContentIsDeprecated() { if (!emptyContentDeprecatedWarned) { emptyContentDeprecatedWarned = true console.warn( '[ali-react-table] BaseTable props.emptyContent 已经过时,请使用 props.components.EmptyContent 来自定义数据为空时的表格表现', ) } } export type PrimaryKey = string | ((row: any) => string) export interface BaseTableProps { /** 主键 */ primaryKey?: PrimaryKey /** 表格展示的数据源 */ dataSource: any[] /** 表格页脚数据源 */ footerDataSource?: any[] /** 表格的列配置 */ columns: ArtColumn[] /** 是否开启虚拟滚动 */ useVirtual?: VirtualEnum | { horizontal?: VirtualEnum; vertical?: VirtualEnum; header?: VirtualEnum } /** 虚拟滚动开启情况下,表格中每一行的预估高度 */ estimatedRowHeight?: number /** @deprecated 表格头部是否置顶,默认为 true。请使用 isStickyHeader 代替 */ isStickyHead?: boolean /** 表格头部是否置顶,默认为 true */ isStickyHeader?: boolean /** 表格置顶后,距离顶部的距离 */ stickyTop?: number /** 表格页脚是否置底,默认为 true */ isStickyFooter?: boolean /** 表格页脚置底后,距离底部的距离 */ stickyBottom?: number /** 自定义类名 */ className?: string /** 自定义内联样式 */ style?: CSSProperties & BaseTableCSSVariables /** 表格是否具有头部 */ hasHeader?: boolean /** 表格是否具有横向的粘性滚动条 */ hasStickyScroll?: boolean /** 横向粘性滚动条高度 */ stickyScrollHeight?: 'auto' | number /** 使用来自外层 div 的边框代替单元格的外边框 */ useOuterBorder?: boolean useArtTableBorder?: boolean /** 表格是否在加载中 */ isLoading?: boolean /** 数据为空时,单元格的高度 */ emptyCellHeight?: number /** @deprecated 数据为空时,表格的展示内容。请使用 components.EmptyContent 代替 */ emptyContent?: ReactNode /** 覆盖表格内部用到的组件 */ components?: { /** 表格加载时,表格内容的父组件 */ LoadingContentWrapper?: React.ComponentType /** 表格加载时的加载图标 */ LoadingIcon?: React.ComponentType /** 数据为空时,表格的展示内容。*/ EmptyContent?: React.ComponentType /** 覆盖内部渲染 tbody>tr 元素的组件,一般用于在 tr 上设置 context */ Row?: React.ComponentType<{ row: any; rowIndex: number; trProps: unknown }> /** 覆盖内部渲染 tbody>td 元素的组件,一般用于在 td 上设置 context */ Cell?: React.ComponentType<{ row: any; rowIndex: number; colIndex: number; tdProps: unknown; column: ArtColumn }> /** 覆盖内部渲染 tbody 元素的组件 */ TableBody?: React.ComponentType<{ tbodyProps: unknown }> } /** 列的默认宽度 */ defaultColumnWidth?: number /** 虚拟滚动调试标签,用于表格内部调试使用 */ virtualDebugLabel?: string getRowProps?(row: any, rowIndex: number): React.HTMLAttributes setTableDomHelper?(domHelper: TableDOMHelper): void /**上下多展示多少个数据*/ overflowVerticalNumber?: number /**头部渲染右侧内容*/ topRightContent?: React.ReactNode /**头部渲染左侧内容*/ topLeftContent?: React.ReactNode /**拖拽类型 * @description 使用的时候需要表头使用 makeRecursiveMapperWithPathIndex 方法进行转换 */ dragType?: "column" | "columnGroup" /**表格实例*/ instance?: BaseTableInstance /**列拖拽结束*/ onColumnDragEnd?: (param: OnUpdatedOptions, instance: DragInstance) => void /**拖拽实例*/ dragInstance?: DragInstance } interface BaseTableState { /** 是否要展示自定义滚动条(stickyScroll) */ hasScroll: boolean /** 是否需要渲染 lock sections * 当表格较宽时,所有的列都能被完整的渲染,此时不需要渲染 lock sections * 只有当「整个表格的宽度」小于「每一列渲染宽度之和」时,lock sections 才需要被渲染 */ needRenderLock: boolean /** 纵向虚拟滚动偏移量 */ offsetY: number /** 纵向虚拟滚动 最大渲染尺寸 */ maxRenderHeight: number /** 横向虚拟滚动偏移量 */ offsetX: number /** 横向虚拟滚动 最大渲染尺寸 */ maxRenderWidth: number /**头部内容高度*/ headerContentHeight?: number } export class BaseTable extends React.Component { static defaultProps = { hasHeader: true, isStickyHeader: true, stickyTop: 0, footerDataSource: [] as any[], isStickyFooter: true, stickyBottom: 0, hasStickyScroll: true, stickyScrollHeight: 'auto', useVirtual: 'auto', estimatedRowHeight: 48, isLoading: false, components: {}, getRowProps: noop, dataSource: [] as any[], } static useBaseTableInstance: typeof useBaseTableInstance; static useDragInstance: typeof useDragInstance private rowHeightManager = makeRowHeightManager(this.props.dataSource.length, this.props.estimatedRowHeight) private artTableWrapperRef = React.createRef() private domHelper: TableDOMHelper private rootSubscription = new Subscription() // 最近一次渲染的计算结果缓存 private lastInfo: RenderInfo private props$: BehaviorSubject /**基础实例*/ private baseTableInstance: BaseTableInstance = new BaseTableInstance(); /**拖拽基础实例*/ private dragInstance: DragInstance = new DragInstance(); /** @deprecated BaseTable.getDoms() 已经过时,请勿调用 */ getDoms() { console.warn('[ali-react-table] BaseTable.getDoms() 已经过时') return this.domHelper } constructor(props: Readonly) { super(props) this.state = { hasScroll: true, needRenderLock: true, offsetY: 0, offsetX: 0, // 因为 ResizeObserver 在一开始总是会调用一次所提供的回调函数 // 故这里为 maxRenderHeight/maxRenderWidth 设置一个默认值即可(因为这两个默认值很快就会被覆盖) // https://stackoverflow.com/questions/60026223/does-resizeobserver-invokes-initially-on-page-load maxRenderHeight: 600, maxRenderWidth: 800, headerContentHeight: 0,// 头部高度 } this.setupInstance() } private setupInstance() { /**初始数据*/ if (this.props.dragInstance) { this.dragInstance = this.props.dragInstance } if (this.props.instance) { this.baseTableInstance = this.props.instance } this.baseTableInstance.baseTable = this;// 把当前实例进行存储 this.baseTableInstance.dragInstance = this.dragInstance; this.dragInstance.tableInstance = this.baseTableInstance; } /** 自定义滚动条宽度为table宽度,使滚动条滑块宽度相同 */ private updateStickyScroll() { const { stickyScroll, artTable, stickyScrollItem } = this.domHelper if (!artTable) { return } const tableBodyHtmlTable = this.domHelper.getTableBodyHtmlTable() const innerTableWidth = tableBodyHtmlTable.offsetWidth const artTableWidth = artTable.offsetWidth const stickyScrollHeightProp = this.props.stickyScrollHeight const stickyScrollHeight = stickyScrollHeightProp === 'auto' ? getScrollbarSize().height : stickyScrollHeightProp stickyScroll.style.marginTop = `-${stickyScrollHeight + 1}px` if (artTableWidth >= innerTableWidth) { if (this.state.hasScroll) { this.setState({ hasScroll: false }) } } else { if (!this.state.hasScroll && stickyScrollHeight > 5) { // 考虑下mac下面隐藏滚动条的情况 this.setState({ hasScroll: true }) } } // 设置子节点宽度 stickyScrollItem.style.width = `${innerTableWidth}px` } private renderTableHeaderTop(info: RenderInfo) { const { stickyTop, hasHeader, topRightContent, topLeftContent, dragType } = this.props if (dragType !== "columnGroup" && !topLeftContent && !topRightContent) { return } return ( {topLeftContent} {dragType === 'columnGroup' ? : } {topRightContent} ) } private renderTableHeader(info: RenderInfo) { const { stickyTop, hasHeader, } = this.props const { headerContentHeight = 0 } = this.state return (
) } private updateOffsetX(nextOffsetX: number) { if (this.lastInfo.useVirtual.horizontal) { if (Math.abs(nextOffsetX - this.state.offsetX) >= OVERSCAN_SIZE / 2) { this.setState({ offsetX: nextOffsetX }) } } } private syncHorizontalScrollFromTableBody() { this.syncHorizontalScroll(this.domHelper.tableBody.scrollLeft) } /** 同步横向滚动偏移量 */ private syncHorizontalScroll(x: number) { const { tableBody, artTableWrapper } = this.domHelper const { flat } = this.lastInfo this.updateOffsetX(x) if (artTableWrapper) { const shouldShowLeftLockShadow = flat.left.length > 0 && this.state.needRenderLock && x > 0 if (shouldShowLeftLockShadow) { artTableWrapper.classList.add('show-left-shadow') } else { artTableWrapper.classList.remove('show-left-shadow') } const shouldShowRightLockShadow = flat.right.length > 0 && this.state.needRenderLock && x < tableBody.scrollWidth - tableBody.clientWidth if (shouldShowRightLockShadow) { artTableWrapper.classList.add('show-right-shadow') } else { artTableWrapper.classList.remove('show-right-shadow') } } // const leftLockShadow = this.domHelper.getLeftLockShadow() // if (leftLockShadow) { // const shouldShowLeftLockShadow = flat.left.length > 0 && this.state.needRenderLock && x > 0 // if (shouldShowLeftLockShadow) { // leftLockShadow.classList.add('show-shadow') // } else { // leftLockShadow.classList.remove('show-shadow') // } // } // const rightLockShadow = this.domHelper.getRightLockShadow() // if (rightLockShadow) { // const shouldShowRightLockShadow = // flat.right.length > 0 && this.state.needRenderLock && x < tableBody.scrollWidth - tableBody.clientWidth // if (shouldShowRightLockShadow) { // rightLockShadow.classList.add('show-shadow') // } else { // rightLockShadow.classList.remove('show-shadow') // } // } } getVerticalRenderRange(useVirtual: ResolvedUseVirtual): VerticalRenderRange { const { dataSource } = this.props const { offsetY, maxRenderHeight } = this.state const rowCount = dataSource.length if (useVirtual.vertical) { return this.rowHeightManager.getRenderRange(offsetY, maxRenderHeight, rowCount) } else { return getFullRenderRange(rowCount) } } private renderTableBody(info: RenderInfo) { const { dataSource, getRowProps, primaryKey, isLoading, emptyCellHeight, footerDataSource, components } = this.props const tableBodyClassName = cx(Classes.tableBody, Classes.horizontalScrollContainer, { 'no-scrollbar': footerDataSource.length > 0, }) if (dataSource.length === 0) { const { components, emptyContent } = this.props let EmptyContent = components.EmptyContent if (EmptyContent == null && emptyContent != null) { warnEmptyContentIsDeprecated() EmptyContent = (() => emptyContent) as unknown as React.ComponentType } return (
) } const { topIndex, bottomBlank, topBlank, bottomIndex, startTopIndex = topIndex, endBottomIndex = bottomIndex } = info.verticalRenderRange return (
{topBlank > 0 && (
)} {bottomBlank > 0 && (
)}
) } private renderTableFooter(info: RenderInfo) { const { footerDataSource = [], getRowProps, primaryKey, stickyBottom, components, } = this.props return (
) } private renderStickyScroll(info: RenderInfo) { const { hasStickyScroll, stickyBottom } = this.props const { hasScroll } = this.state return (
) } render() { const info = calculateRenderInfo(this) this.lastInfo = info this.dragInstance.onUpdated = this.props.onColumnDragEnd const { dataSource, className, style, hasHeader, useOuterBorder, isStickyHead, isStickyHeader, isStickyFooter, isLoading, footerDataSource, components, useArtTableBorder, } = this.props const artTableWrapperClassName = cx( Classes.artTableWrapper, { 'use-outer-border': useOuterBorder, "use-art-table-border": useArtTableBorder, empty: dataSource.length === 0, lock: info.hasLockColumn, 'has-header': hasHeader, 'sticky-header': isStickyHeader ?? isStickyHead, 'has-footer': footerDataSource.length > 0, 'sticky-footer': isStickyFooter, }, className, ) const artTableWrapperProps = { className: artTableWrapperClassName, style, [STYLED_REF_PROP]: this.artTableWrapperRef, } return (
{this.renderTableHeaderTop(info)} {this.renderTableHeader(info)} {this.renderTableBody(info)} {this.renderTableFooter(info)}
{this.renderStickyScroll(info)}
) } componentDidMount() { this.updateDOMHelper() this.props$ = new BehaviorSubject(this.props) this.initSubscriptions() this.didMountOrUpdate() this.props.setTableDomHelper?.(this.domHelper) } componentDidUpdate(prevProps: Readonly, prevState: Readonly) { this.updateDOMHelper() this.props$.next(this.props) this.didMountOrUpdate(prevProps, prevState) } private didMountOrUpdate(prevProps?: Readonly, prevState?: Readonly) { this.syncHorizontalScrollFromTableBody() this.updateStickyScroll() this.adjustNeedRenderLock() this.updateRowHeightManager() this.updateScrollLeftWhenLayoutChanged(prevProps, prevState) } private updateScrollLeftWhenLayoutChanged( prevProps?: Readonly, prevState?: Readonly, ) { if (prevState != null) { if (!prevState.hasScroll && this.state.hasScroll) { this.domHelper.stickyScroll.scrollLeft = 0 } } if (prevProps != null) { const prevHasFooter = prevProps.footerDataSource.length > 0 const currentHasFooter = this.props.footerDataSource.length > 0 if (!prevHasFooter && currentHasFooter) { this.domHelper.tableFooter.scrollLeft = this.domHelper.tableBody.scrollLeft } } } private initSubscriptions() { const { tableHeader, tableBody, tableFooter, stickyScroll } = this.domHelper this.rootSubscription.add( throttledWindowResize$.subscribe(() => { this.updateStickyScroll() this.adjustNeedRenderLock() }), ) if (this.domHelper.tableHeaderTop) { this.rootSubscription.add( fromResizeEvent(this.domHelper.tableHeaderTop).subscribe((entry: ResizeObserverEntry[]) => { if (entry && entry[0]) { const [borderBoxSize] = entry[0].borderBoxSize this.setState({ headerContentHeight: borderBoxSize.blockSize }) } }), ) } if (this.props.dragType) { /**用于拖拽列表头和滚动条变动*/ this.rootSubscription.add( fromEvent>(document, "dragover").subscribe((event) => { // 处理横向滚动 // event.clientX const rect = this.domHelper.artTableWrapper.getBoundingClientRect(); if (rect.right <= event.clientX) { // 这个的时候慢慢滚动 if (this.domHelper.tableBody.scrollLeft < this.domHelper.tableBody.scrollWidth) { this.domHelper.tableBody.scrollLeft = this.domHelper.tableBody.scrollLeft + 10; } else { this.domHelper.tableBody.scrollLeft = this.domHelper.tableBody.scrollWidth; } } else if (rect.left >= event.clientX) { if (this.domHelper.tableBody.scrollLeft > 0) { this.domHelper.tableBody.scrollLeft = this.domHelper.tableBody.scrollLeft - 10; } else { this.domHelper.tableBody.scrollLeft = 0; } } }) ) } // 滚动同步 this.rootSubscription.add( syncScrollLeft([tableHeader, tableBody, tableFooter, stickyScroll], (scrollLeft) => { this.syncHorizontalScroll(scrollLeft) }), ) const richVisibleRects$ = getRichVisibleRectsStream( this.domHelper.artTable, this.props$.pipe(op.skip(1), op.mapTo('structure-may-change')), this.props.virtualDebugLabel, ).pipe(op.shareReplay()) // 每当可见部分发生变化的时候,调整 loading icon 的未知(如果 loading icon 存在的话) this.rootSubscription.add( combineLatest([ richVisibleRects$.pipe( op.map((p) => p.clipRect), op.distinctUntilChanged(shallowEqual), ), this.props$.pipe( op.startWith(null), op.pairwise(), op.filter(([prevProps, props]) => prevProps == null || (!prevProps.isLoading && props.isLoading)), ), ]).subscribe(([clipRect]) => { const loadingIndicator = this.domHelper.getLoadingIndicator() if (!loadingIndicator) { return } const height = clipRect.bottom - clipRect.top // fixme 这里的定位在有些特殊情况下可能会出错 see #132 loadingIndicator.style.top = `${height / 2}px` loadingIndicator.style.marginTop = `${height / 2}px` }), ) // 每当可见部分发生变化的时候,如果开启了虚拟滚动,则重新触发 render this.rootSubscription.add( richVisibleRects$ .pipe( op.filter(() => { const { horizontal, vertical } = this.lastInfo.useVirtual return horizontal || vertical }), op.map(({ clipRect, offsetY }) => ({ maxRenderHeight: clipRect.bottom - clipRect.top, maxRenderWidth: clipRect.right - clipRect.left, offsetY, })), op.distinctUntilChanged((x, y) => { // 因为 overscan 的存在,滚动较小的距离时不需要触发组件重渲染 return ( Math.abs(x.maxRenderWidth - y.maxRenderWidth) < OVERSCAN_SIZE / 2 && Math.abs(x.maxRenderHeight - y.maxRenderHeight) < OVERSCAN_SIZE / 2 && Math.abs(x.offsetY - y.offsetY) < OVERSCAN_SIZE / 2 ) }), ) .subscribe((sizeAndOffset) => { this.setState(sizeAndOffset) }), ) } componentWillUnmount() { this.rootSubscription.unsubscribe() this.rootSubscription = new Subscription() } /** 更新 DOM 节点的引用,方便其他方法直接操作 DOM */ private updateDOMHelper() { this.domHelper = new TableDOMHelper(this.artTableWrapperRef.current) } private updateRowHeightManager() { const virtualTop = this.domHelper.getVirtualTop() const virtualTopHeight = virtualTop?.clientHeight ?? 0 let zeroHeightRowCount = 0 let maxRowIndex = -1 let maxRowBottom = -1 for (const tr of this.domHelper.getTableRows()) { const rowIndex = Number(tr.dataset.rowindex) if (isNaN(rowIndex)) { continue } maxRowIndex = Math.max(maxRowIndex, rowIndex) const offset = tr.offsetTop + virtualTopHeight const size = tr.offsetHeight if (size === 0) { zeroHeightRowCount += 1 } maxRowBottom = Math.max(maxRowBottom, offset + size) this.rowHeightManager.updateRow(rowIndex, offset, size) } // 当 estimatedRowHeight 过大时,可能出现「渲染行数过少,无法覆盖可视范围」的情况 // 出现这种情况时,我们判断「下一次渲染能够渲染更多行」是否满足,满足的话就直接调用 forceUpdate // zeroHeightRowCount === 0 用于确保当前没有 display=none 的情况 if (maxRowIndex !== -1 && zeroHeightRowCount === 0) { if (maxRowBottom < this.state.offsetY + this.state.maxRenderHeight) { const vertical = this.getVerticalRenderRange(this.lastInfo.useVirtual) if (vertical.bottomIndex - 1 > maxRowIndex) { this.forceUpdate() } } } } /** 计算表格所有列的渲染宽度之和,判断表格是否需要渲染锁列 */ private adjustNeedRenderLock() { const { needRenderLock } = this.state const { flat, hasLockColumn } = this.lastInfo if (hasLockColumn) { const sumOfColWidth = sum(flat.full.map((col) => col.width)) const nextNeedRenderLock = sumOfColWidth > this.domHelper.artTable.clientWidth if (needRenderLock !== nextNeedRenderLock) { this.setState({ needRenderLock: nextNeedRenderLock }) } } else { if (needRenderLock) { this.setState({ needRenderLock: false }) } } } } BaseTable.useBaseTableInstance = useBaseTableInstance BaseTable.useDragInstance = useDragInstance