UNPKG

@antv/s2

Version:

effective spreadsheet render core lib

503 lines 20.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StickyHeaderController = void 0; const tslib_1 = require("tslib"); const lodash_1 = require("lodash"); const constant_1 = require("../../common/constant"); const merge_1 = require("../../utils/merge"); var StickyState; (function (StickyState) { /** 未吸顶 (表格在视口下方或已完全滚出) */ StickyState["UN_STICKY"] = "UN_STICKY"; /** 完全吸顶 (表头固定在视口顶部) */ StickyState["STICKY"] = "STICKY"; /** 吸顶边缘 (表格底部即将离开视口, 表头跟随滚出) */ StickyState["STICKY_EDGE"] = "STICKY_EDGE"; })(StickyState || (StickyState = {})); /** * Window 级别表头吸顶控制器 * * 在 s2-core 层级通过双实例 + DOM 代理包装实现框架无关的表头吸顶功能 * 当表格高度超出页面可视区域时, 表头会自动吸附在视口顶部 */ class StickyHeaderController { constructor(spreadsheet) { /** 吸顶表头的 S2 实例 (仅渲染表头) */ this.stickyS2 = null; /** 吸顶表头的容器 DOM */ this.wrapperElement = null; /** 吸顶表头 S2 的挂载容器 */ this.stickyContainer = null; this.stickyState = StickyState.UN_STICKY; /** 解除事件监听的回调集合 */ this.disposers = []; /** 缓存的滚动容器 */ this.scrollContainer = window; /** 布局同步防回环锁 */ this.isSyncing = false; /** * 核心: 计算三态样式并应用到吸顶容器 * * 同时支持 window 滚动和 div 容器滚动两种场景: * - window: 使用 position:fixed, top 为视口偏移 * - div: 使用 position:absolute, top 为相对 S2 容器的偏移 */ this.syncStyle = () => { if (!this.wrapperElement) { return; } const tableBox = this.getTableBox(); if (!tableBox) { return; } const { headerHeight, tableBottom, tableHeight, tableLeft, tableWidth, tableTop, } = tableBox; const offsetTop = this.getOffsetTop(); // 对于 window 滚动, 参考线 = offsetTop (相对视口顶部) // 对于 div 滚动, 参考线 = 容器可见顶部 + offsetTop (相对视口) const refLine = this.isWindowScroll() ? offsetTop : this.scrollContainer.getBoundingClientRect().top + offsetTop; const { style } = this.wrapperElement; // 1. 未吸顶: 表格顶部在参考线以下, 或表格底部在参考线以上 if (refLine < tableTop || refLine > tableTop + tableHeight) { this.stickyState = StickyState.UN_STICKY; style.display = 'none'; return; } // 通用属性 style.display = ''; style.height = `${headerHeight}px`; // 2. 完全吸顶: 表头上边缘已滚出, 但表格底部还未进入吸顶表头区域 if (tableTop < refLine && refLine < tableBottom - headerHeight) { this.stickyState = StickyState.STICKY; if (this.isWindowScroll()) { // window 滚动: fixed 定位相对视口 style.position = 'fixed'; style.top = `${refLine}px`; style.left = `${tableLeft}px`; style.width = `${tableWidth}px`; style.right = ''; } else { // div 滚动: absolute 定位相对 S2 容器 style.position = 'absolute'; style.top = `${refLine - tableTop}px`; style.left = '0'; style.right = '0'; style.width = ''; } return; } // 3. 吸顶边缘: 表格底部边缘进入表头高度范围, 表头开始跟随滚出 if (tableBottom - headerHeight <= refLine && refLine <= tableBottom) { this.stickyState = StickyState.STICKY_EDGE; style.position = 'absolute'; style.top = `${tableHeight - headerHeight}px`; style.left = '0'; style.right = '0'; style.width = ''; } }; this.spreadsheet = spreadsheet; this.options = this.resolveOptions(); this.scrollContainer = this.resolveScrollContainer(); this.init(); } getStickyState() { return this.stickyState; } resolveOptions() { var _a; const cfg = (_a = this.spreadsheet.options.interaction) === null || _a === void 0 ? void 0 : _a.stickyHeader; if (!cfg || (0, lodash_1.isBoolean)(cfg)) { return {}; } return cfg; } // ==================== 初始化 ==================== init() { this.createDOM(); this.createStickyS2(); this.bindScrollListener(); this.bindSyncListeners(); this.bindInteractionBridge(); // 首次渲染吸顶表头 (此时主表的 LAYOUT_AFTER_RENDER 已触发过, 需手动触发一次) this.renderStickyS2(); } /** * 创建吸顶表头容器 DOM */ createDOM() { const mainCanvas = this.spreadsheet.getCanvasElement(); if (!mainCanvas) { return; } const container = mainCanvas.parentElement; // 确保容器有定位上下文 const computedPos = window.getComputedStyle(container).position; if (computedPos === 'static') { container.style.position = 'relative'; } // 创建吸顶表头的外层包装器 this.wrapperElement = document.createElement('div'); this.wrapperElement.className = 's2-sticky-header-wrapper'; Object.assign(this.wrapperElement.style, { position: 'absolute', top: '0', left: '0', right: '0', zIndex: '10', overflow: 'hidden', display: 'none', }); // 创建 S2 挂载容器 this.stickyContainer = document.createElement('div'); this.wrapperElement.appendChild(this.stickyContainer); container.insertBefore(this.wrapperElement, mainCanvas); } // ==================== 吸顶 S2 实例 ==================== /** * 压缩 S2 数据配置, 仅保留渲染表头的必要数据 * * columns 中指定了列头所使用的维值的 key, 在数据项中对应的值组合起来即为一个列头 * 对于相同列头值的数据, 只保留一项即可 */ minimizeDataCfg(dataCfg) { var _a; const columns = (_a = dataCfg === null || dataCfg === void 0 ? void 0 : dataCfg.fields) === null || _a === void 0 ? void 0 : _a.columns; if (!columns) { return dataCfg; } const cache = {}; dataCfg.data.forEach((item) => { const values = columns.map((column) => { const key = (0, lodash_1.isString)(column) ? column : column.field; return String(item[key]); }); const cacheKey = values.join('\u0000'); if (!cache[cacheKey]) { cache[cacheKey] = item; } }); return Object.assign(Object.assign({}, dataCfg), { data: Object.values(cache) }); } /** * 生成吸顶表头的 Options * * 当 enableInteraction 为 true 时, 保留 resize/tooltip/headerActionIcons 等配置 * 仅禁用刷选/多选等不适用于表头的交互 */ applyStickyOptions(options) { var _a; const enableInteraction = this.options.enableInteraction; if (enableInteraction) { return (0, merge_1.customMerge)(options, { style: { dataCell: { height: 0 } }, interaction: { resize: (_a = options.interaction) === null || _a === void 0 ? void 0 : _a.resize, stickyHeader: false, brushSelection: false, multiSelection: false, rangeSelection: false, selectedCellMove: false, }, tooltip: { enable: true, operation: { hiddenColumns: false, }, }, }); } return (0, merge_1.customMerge)(options, { style: { dataCell: { height: 0 } }, interaction: { resize: false, stickyHeader: false, }, tooltip: { enable: false, }, headerActionIcons: [], showDefaultHeaderActionIcon: false, }); } /** * 基于主表当前配置, 生成吸顶表头的配置 */ toStickyConfig() { const { dataCfg, options } = this.spreadsheet; return { dataCfg: this.minimizeDataCfg(dataCfg), options: this.applyStickyOptions(options), }; } /** * 创建吸顶 S2 实例 * * 核心: 通过 spreadsheet.constructor 创建同类型的 S2 实例 (PivotSheet / TableSheet) * 确保和主表使用相同的渲染逻辑 */ createStickyS2() { if (!this.stickyContainer) { return; } const { dataCfg, options } = this.toStickyConfig(); // 利用主表的构造函数创建同类型实例 const SheetClass = this.spreadsheet.constructor; this.stickyS2 = new SheetClass(this.stickyContainer, dataCfg, options); } /** * 渲染吸顶 S2 并调整到表头大小 */ renderStickyS2() { return tslib_1.__awaiter(this, void 0, void 0, function* () { var _a, _b, _c; if (!this.stickyS2) { return; } const { facet } = this.spreadsheet; if (!facet) { return; } const mainConfig = this.spreadsheet.getCanvasConfig(); const headerHeight = (_b = (_a = facet.cornerBBox) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0; const width = (_c = mainConfig.width) !== null && _c !== void 0 ? _c : 0; this.stickyS2.changeSheetSize(width, headerHeight); yield this.stickyS2.render(); }); } // ==================== 滚动监听 & 样式计算 ==================== getOffsetTop() { const { offsetTop } = this.options; if ((0, lodash_1.isFunction)(offsetTop)) { return offsetTop(); } if ((0, lodash_1.isNumber)(offsetTop)) { return offsetTop; } return 0; } /** * 沿 DOM 树向上查找最近的**实际正在滚动**的祖先容器 * * 仅当某个祖先同时满足以下两个条件时才返回: * 1. CSS overflow/overflow-y/overflow-x 为 auto | scroll | overlay * 2. scrollHeight > clientHeight (内容确实溢出, 正在产生滚动) * * 这样可以跳过 Ant Tabs 等 overflow:auto 但内容未溢出的容器 */ getScrollParent(node) { if (!node) { return window; } let parent = node.parentElement; while (parent && parent !== document.body && parent !== document.documentElement) { const style = window.getComputedStyle(parent); const overflow = style.getPropertyValue('overflow') + style.getPropertyValue('overflow-y') + style.getPropertyValue('overflow-x'); if (/(?:auto|scroll|overlay)/.test(overflow) && parent.scrollHeight > parent.clientHeight) { return parent; } parent = parent.parentElement; } return window; } resolveScrollContainer() { if (this.options.scrollContainer) { return this.options.scrollContainer; } const canvas = this.spreadsheet.getCanvasElement(); return this.getScrollParent(canvas); } isWindowScroll() { return this.scrollContainer === window; } /** * 获取主表的位置和尺寸信息 */ getTableBox() { var _a, _b; const mainCanvas = this.spreadsheet.getCanvasElement(); if (!mainCanvas) { return null; } const canvasBox = mainCanvas.getBoundingClientRect(); const { facet } = this.spreadsheet; if (!facet) { return null; } const tableTop = canvasBox.top; const tableLeft = canvasBox.left; const tableWidth = canvasBox.width; const headerHeight = (_b = (_a = facet.cornerBBox) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0; const canvasHeight = canvasBox.height; const containerHeight = facet.getContentHeight(); const tableHeight = Math.min(containerHeight, canvasHeight); const tableBottom = tableTop + tableHeight; return { tableTop, tableLeft, tableWidth, tableHeight, tableBottom, headerHeight, }; } bindScrollListener() { const onScroll = (0, lodash_1.throttle)(this.syncStyle, 16); this.scrollContainer.addEventListener('scroll', onScroll, { passive: true, }); this.disposers.push(() => { this.scrollContainer.removeEventListener('scroll', onScroll); }); } // ==================== 主从同步 ==================== /** * 横向滚动同步: 主表横滑时, 吸顶 S2 的表头跟着同步横滑 * 布局变更同步: 当主表列宽/表格大小变化时, 重新渲染吸顶表头 */ bindSyncListeners() { const { spreadsheet } = this; // 1. 横向滚动同步 (参考 StrategySheetPro syncScroll) const onGlobalScroll = (position) => { var _a; if (!((_a = this.stickyS2) === null || _a === void 0 ? void 0 : _a.facet)) { return; } this.stickyS2.facet.updateScrollOffset({ offsetX: { value: position.scrollX, animate: false }, offsetY: { value: position.scrollY, animate: false }, rowHeaderOffsetX: { value: position.rowHeaderScrollX, animate: false, }, }); }; spreadsheet.on(constant_1.S2Event.GLOBAL_SCROLL, onGlobalScroll); this.disposers.push(() => { spreadsheet.off(constant_1.S2Event.GLOBAL_SCROLL, onGlobalScroll); }); // 2. 布局变更同步 (参考 StrategySheetPro syncLayout) const onLayoutChange = () => { if (!this.stickyS2 || this.isSyncing) { return; } this.isSyncing = true; try { const { dataCfg, options } = this.toStickyConfig(); this.stickyS2.setDataCfg(dataCfg); this.stickyS2.setOptions(options); // 调整画布大小到表头大小 const mainConfig = spreadsheet.getCanvasConfig(); this.stickyS2.changeSheetSize(mainConfig.width, spreadsheet.facet.cornerBBox.height); this.stickyS2.render(false); this.syncStyle(); } finally { this.isSyncing = false; } }; const layoutEvents = [constant_1.S2Event.LAYOUT_AFTER_RENDER, constant_1.S2Event.LAYOUT_RESIZE]; layoutEvents.forEach((event) => { spreadsheet.on(event, onLayoutChange); }); this.disposers.push(() => { layoutEvents.forEach((event) => { spreadsheet.off(event, onLayoutChange); }); }); } // ==================== 交互桥接 ==================== /** * 交互桥接: 监听副表的语义事件, 转译为主表的等效操作 * * - Resize: 提取 style 应用到主表, 主表 render 后 onLayoutChange 自动同步副表 * - Sort: RANGE_SORT 是命令事件, 主表 facet 内部直接处理 * - Collapse: ROW_CELL_COLLAPSED__PRIVATE 是命令事件, PivotSheet 内部直接处理 */ bindInteractionBridge() { if (!this.options.enableInteraction || !this.stickyS2) { return; } const { stickyS2, spreadsheet } = this; // 1. Resize 桥接 (列宽/行高/列头高) // 副表 resize 交互完成后会 emit LAYOUT_RESIZE 并携带 ResizeParams // 提取其中的 style 应用到主表, 主表 render 后 onLayoutChange 会自动同步副表 stickyS2.on(constant_1.S2Event.LAYOUT_RESIZE, (resizeDetail) => tslib_1.__awaiter(this, void 0, void 0, function* () { if (resizeDetail.style) { spreadsheet.setOptions({ style: resizeDetail.style }); yield spreadsheet.render(false); } spreadsheet.emit(constant_1.S2Event.LAYOUT_RESIZE, resizeDetail); })); // 2. 排序桥接 // 透视表排序通过 groupSortByMethod → setDataCfg({ sortParams }) 完成 // RANGE_SORT 在透视表中仅是通知事件, 不足以触发排序 // 需要直接将 sortParams apply 到主表 dataCfg stickyS2.on(constant_1.S2Event.RANGE_SORT, (sortParams) => tslib_1.__awaiter(this, void 0, void 0, function* () { spreadsheet.setDataCfg(Object.assign(Object.assign({}, spreadsheet.dataCfg), { sortParams })); yield spreadsheet.render(); spreadsheet.emit(constant_1.S2Event.RANGE_SORT, sortParams); })); // 3. 树节点展开/折叠桥接 (单个节点) // ROW_CELL_COLLAPSED 是通知事件, 需要转发为 __PRIVATE 命令事件 stickyS2.on(constant_1.S2Event.ROW_CELL_COLLAPSED, (params) => { spreadsheet.emit(constant_1.S2Event.ROW_CELL_COLLAPSED__PRIVATE, params); this.scrollToTableTop(); }); // 4. 角头全量展开/折叠桥接 // 副表 handleRowCellToggleCollapseAll 已经做了 !isCollapsed 取反 // emit ROW_CELL_ALL_COLLAPSED 携带的是最终状态 collapseAll // 不能再通过 __PRIVATE (内含取反) 转发, 否则会双重取反 // 直接将最终状态 apply 到主表 stickyS2.on(constant_1.S2Event.ROW_CELL_ALL_COLLAPSED, (collapseAll) => tslib_1.__awaiter(this, void 0, void 0, function* () { spreadsheet.setOptions({ style: { rowCell: { collapseAll, collapseFields: null, expandDepth: null, }, }, }); yield spreadsheet.render(false); spreadsheet.emit(constant_1.S2Event.ROW_CELL_ALL_COLLAPSED, collapseAll); this.scrollToTableTop(); })); } /** * 折叠/展开后滚动到表格顶部 * 折叠操作会大幅改变表格高度, 原位置可能导致用户迷失 */ scrollToTableTop() { var _a; const canvas = this.spreadsheet.getCanvasElement(); (_a = canvas === null || canvas === void 0 ? void 0 : canvas.scrollIntoView) === null || _a === void 0 ? void 0 : _a.call(canvas, { behavior: 'smooth', block: 'start' }); } // ==================== 生命周期 ==================== destroy() { this.disposers.forEach((dispose) => dispose()); this.disposers = []; if (this.stickyS2) { this.stickyS2.destroy(); this.stickyS2 = null; } if (this.wrapperElement) { this.wrapperElement.remove(); this.wrapperElement = null; } this.stickyContainer = null; this.stickyState = StickyState.UN_STICKY; } } exports.StickyHeaderController = StickyHeaderController; //# sourceMappingURL=index.js.map