diff --git a/common/changes/@visactor/vtable/feat-frozenColumn_scroll_2026-03-20-11-09.json b/common/changes/@visactor/vtable/feat-frozenColumn_scroll_2026-03-20-11-09.json new file mode 100644 index 0000000000..4f2c6adbc4 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-frozenColumn_scroll_2026-03-20-11-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: add option scrollFrozenCols\n\n", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} \ No newline at end of file diff --git a/docs/assets/demo/en/interaction/scroll-frozen.md b/docs/assets/demo/en/interaction/scroll-frozen.md new file mode 100644 index 0000000000..f77be79bcf --- /dev/null +++ b/docs/assets/demo/en/interaction/scroll-frozen.md @@ -0,0 +1,62 @@ +--- +category: examples +group: Interaction +title: Scrollbars In Frozen Areas +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/scroll-frozen.gif +link: interaction/scroll-frozen +option: ListTable#scrollFrozenCols +--- + +# Scrollbars In Frozen Areas + +This example shows horizontal scrolling inside frozen areas (both left and right) when the total width of frozen columns exceeds the maximum frozen width. + +## Key Options + +- `frozenColCount` / `rightFrozenColCount` set left/right frozen columns count +- `maxFrozenWidth` / `maxRightFrozenWidth` set the maximum frozen area width +- `scrollFrozenCols` / `scrollRightFrozenCols` enable horizontal scrolling inside frozen areas +- `theme.scrollStyle.visible` can be used to observe scrollbar visibility behavior across multiple scrollable regions (e.g. `scrolling` / `focus`) + +## Code + +```javascript livedemo template=vtable +let tableInstance; +fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_data.json') + .then(res => res.json()) + .then(data => { + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 160 }, + { field: 'Customer ID', title: 'Customer ID', width: 160 }, + { field: 'Product Name', title: 'Product Name', width: 220 }, + { field: 'Category', title: 'Category', width: 140 }, + { field: 'Sub-Category', title: 'Sub-Category', width: 160 }, + { field: 'Region', title: 'Region', width: 120 }, + { field: 'City', title: 'City', width: 140 }, + { field: 'Order Date', title: 'Order Date', width: 140 }, + { field: 'Region', title: 'Region', width: 120 }, + { field: 'City', title: 'City', width: 140 }, + { field: 'Order Date', title: 'Order Date', width: 140 }, + { field: 'Quantity', title: 'Quantity', width: 120 }, + { field: 'Sales', title: 'Sales', width: 120 }, + { field: 'Profit', title: 'Profit', width: 120 }, + { field: 'Segment', title: 'Segment', width: 140 }, + { field: 'Ship Mode', title: 'Ship Mode', width: 140 } + ]; + + const option = { + records: data, + columns, + widthMode: 'standard', + frozenColCount: 4, + rightFrozenColCount: 4, + maxFrozenWidth: 320, + maxRightFrozenWidth: 320, + scrollFrozenCols: true, + scrollRightFrozenCols: true, + overscrollBehavior: 'none' + }; + tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + window['tableInstance'] = tableInstance; + }); +``` diff --git a/docs/assets/demo/menu.json b/docs/assets/demo/menu.json index 467d6ecf8c..107327efa4 100644 --- a/docs/assets/demo/menu.json +++ b/docs/assets/demo/menu.json @@ -439,8 +439,7 @@ "zh": "甘特图缩放滚动条", "en": "Gantt DataZoomAxis Scrollbar" } - } - , + }, { "path": "gantt-baseline", "title": { @@ -920,6 +919,13 @@ "en": "scroll" } }, + { + "path": "scroll-frozen", + "title": { + "zh": "冻结区可滚动", + "en": "Scroll frozen area" + } + }, { "path": "arrowkeys-move-select", "title": { @@ -1845,4 +1851,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/assets/demo/zh/interaction/scroll-frozen.md b/docs/assets/demo/zh/interaction/scroll-frozen.md new file mode 100644 index 0000000000..cdf8e09855 --- /dev/null +++ b/docs/assets/demo/zh/interaction/scroll-frozen.md @@ -0,0 +1,62 @@ +--- +category: examples +group: Interaction +title: 冻结区域滚动条 +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/preview/scroll-frozen.gif +link: interaction/scroll-frozen +option: ListTable#scrollFrozenCols +--- + +# 冻结区域滚动条 + +该示例展示了当冻结区域的列宽总和超过最大冻结宽度时,开启冻结区域内部横向滚动的效果(左冻结与右冻结)。 + +## 关键配置 + +- `frozenColCount` / `rightFrozenColCount` 设置左右冻结列数 +- `maxFrozenWidth` / `maxRightFrozenWidth` 设置左右冻结区域最大宽度 +- `scrollFrozenCols` / `scrollRightFrozenCols` 开启冻结区域内部横向滚动 +- `theme.scrollStyle.visible` 可配合观察滚动条在多滚动区域下的显隐行为(如 `scrolling` / `focus`) + +## 代码演示 + +```javascript livedemo template=vtable +let tableInstance; +fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_data.json') + .then(res => res.json()) + .then(data => { + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 160 }, + { field: 'Customer ID', title: 'Customer ID', width: 160 }, + { field: 'Product Name', title: 'Product Name', width: 220 }, + { field: 'Category', title: 'Category', width: 140 }, + { field: 'Sub-Category', title: 'Sub-Category', width: 160 }, + { field: 'Region', title: 'Region', width: 120 }, + { field: 'City', title: 'City', width: 140 }, + { field: 'Order Date', title: 'Order Date', width: 140 }, + { field: 'Region', title: 'Region', width: 120 }, + { field: 'City', title: 'City', width: 140 }, + { field: 'Order Date', title: 'Order Date', width: 140 }, + { field: 'Quantity', title: 'Quantity', width: 120 }, + { field: 'Sales', title: 'Sales', width: 120 }, + { field: 'Profit', title: 'Profit', width: 120 }, + { field: 'Segment', title: 'Segment', width: 140 }, + { field: 'Ship Mode', title: 'Ship Mode', width: 140 } + ]; + + const option = { + records: data, + columns, + widthMode: 'standard', + frozenColCount: 4, + rightFrozenColCount: 4, + maxFrozenWidth: 320, + maxRightFrozenWidth: 320, + scrollFrozenCols: true, + scrollRightFrozenCols: true, + overscrollBehavior: 'none' + }; + tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); + window['tableInstance'] = tableInstance; + }); +``` diff --git a/docs/assets/guide/en/basic_function/frozen_column_row.md b/docs/assets/guide/en/basic_function/frozen_column_row.md index 23d3832f6a..cc9a20a347 100644 --- a/docs/assets/guide/en/basic_function/frozen_column_row.md +++ b/docs/assets/guide/en/basic_function/frozen_column_row.md @@ -7,13 +7,13 @@ Note: This function is only supported in the basic table ListTable. ## Set Left Frozen Columns Freezing the left column is the most common freezing requirement. Compared with the freezing in other directions, this is also the most comprehensive freezing ability supported by VTable. -f The relevant configuration items are as follows: - `frozenColCount`: Number of frozen columns, default is 0. - `allowFrozenColCount`: Number of columns allowed to be operated, that is, the number of columns before which the freeze operation button will appear, default is 0. - `showFrozenIcon`: Whether to display the fixed column icon, default is `true`. - `maxFrozenWidth`: Maximum freeze width, default is '80%'. +- `scrollFrozenCols`: When the total width of frozen columns exceeds `maxFrozenWidth`, the left frozen area becomes horizontally scrollable, default is `false`. When enabled, all frozen columns are kept and you can use trackpad horizontal scrolling or drag the scrollbar inside the frozen area. - `unfreezeAllOnExceedsMaxWidth`: When the column width exceeds the maximum freeze width, whether to automatically unfreeze all, default is `true`. If set to false, it will not unfreeze all columns, but will determine the number of columns to be unfrozen according to the value of maxFrozenWidth. Here is a configuration example: @@ -36,6 +36,29 @@ A common scenario for freezing the right column in a table is to place operation The configuration items are as follows: - `rightFrozenColCount`: Number of right frozen columns, default is 0. +- `maxRightFrozenWidth`: Maximum freeze width for right frozen columns (fixed value or percentage). Defaults to `maxFrozenWidth`. +- `scrollRightFrozenCols`: When the total width of right frozen columns exceeds `maxRightFrozenWidth`, the right frozen area becomes horizontally scrollable, default is `false`. + +## Horizontal Scrolling Inside Frozen Areas + +When the frozen area's content width exceeds the maximum freeze width, you can enable horizontal scrolling inside the frozen area to keep all frozen columns. + +```javascript +const listTable = new ListTable({ + // ...other configuration items + frozenColCount: 6, + maxFrozenWidth: 320, + scrollFrozenCols: true, + + rightFrozenColCount: 4, + maxRightFrozenWidth: 320, + scrollRightFrozenCols: true +}); +``` + +After enabling: +- The left/right frozen areas respond to trackpad horizontal scrolling within their own regions. +- When scrollbars are visible, independent horizontal scrollbars for frozen areas will appear at the bottom. ## Set Top Frozen Rows diff --git a/docs/assets/guide/en/interaction/scroll.md b/docs/assets/guide/en/interaction/scroll.md index c17db31b5d..929d6cc27d 100644 --- a/docs/assets/guide/en/interaction/scroll.md +++ b/docs/assets/guide/en/interaction/scroll.md @@ -21,6 +21,12 @@ VTable provides rich scroll style configuration items, and users can customize t - barToSide: Whether to display to the edge of the container even though the contents are not full. Default false - ignoreFrozenCols: Ignore the width of frozen columns, default false +When horizontal scrolling inside frozen areas is enabled (for example `scrollFrozenCols` / `scrollRightFrozenCols`), there may be multiple horizontal scrollable regions (body / left frozen / right frozen). In this case: + +- `visible: 'always'`: Scrollbars of all regions are always visible (only if the region is actually scrollable). +- `visible: 'scrolling'`: Scrollbars are shown when scrolling happens or when hovering over the scrollbar area, and will auto-hide after leaving. +- `visible: 'focus'`: Only the scrollbar of the region under the pointer is shown (to avoid multiple scrollbars showing at the same time). + Below we show the effect of these configurations with an example: ```javascript livedemo template=vtable @@ -109,6 +115,33 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American VTable supports horizontal scrolling while holding down the Shift key, or directly dragging the horizontal scroll bar to make it easier for users to browse table data. Of course, if your computer has a touchpad, you can swipe left and right directly on the touchpad to achieve horizontal scrolling. +## Horizontal scrolling inside frozen areas + +After enabling frozen columns, if the frozen area's total frozen width exceeds the maximum frozen width (`maxFrozenWidth` / `maxRightFrozenWidth`), besides auto-unfreezing columns to fit the viewport, you can enable horizontal scrolling inside frozen areas to keep all frozen columns: + +- Left frozen area: enable with `scrollFrozenCols: true` +- Right frozen area: enable with `scrollRightFrozenCols: true` + +After enabling, trackpad horizontal scrolling works inside the corresponding frozen area (without scrolling the body first). When scrollbars are visible, independent horizontal scrollbars for frozen areas will appear at the bottom, supporting dragging the thumb or clicking the track. + +By default (`scrollFrozenColsPassThroughToBody: false`), when you reach the start/end of a frozen area, the body will not scroll. If you want to continue scrolling the body after the frozen area hits its boundary, enable this option. + +Example: + +```javascript +const option = { + // ...other configuration items + frozenColCount: 6, + maxFrozenWidth: 320, + scrollFrozenCols: true, + + rightFrozenColCount: 4, + maxRightFrozenWidth: 320, + scrollRightFrozenCols: true, + scrollFrozenColsPassThroughToBody: false +}; +``` + ## scroll interface VTable provides the scrollToCell interface for scrolling to the specified cell location. The method accepts the cellAddr parameter to specify the cell location to scroll to. Example code is as follows: diff --git a/docs/assets/guide/zh/basic_function/frozen_column_row.md b/docs/assets/guide/zh/basic_function/frozen_column_row.md index 8201bf5e3e..edaffbc4b0 100644 --- a/docs/assets/guide/zh/basic_function/frozen_column_row.md +++ b/docs/assets/guide/zh/basic_function/frozen_column_row.md @@ -7,13 +7,13 @@ ## 左侧冻结列设置 针对左侧列冻结是最常见的冻结需求,相比其他方向的冻结这也是 VTable 支持功能最全面的冻结能力。 -f 相关配置项如下: - `frozenColCount`: 冻结列数,默认为 0。 - `allowFrozenColCount`: 允许可操作冻结列数,即前多少列会出现冻结操作按钮,默认为 0。 - `showFrozenIcon`: 是否显示固定列图标,默认为 `true`。 - `maxFrozenWidth`: 最大冻结宽度,默认为'80%'。 +- `scrollFrozenCols`: 当冻结列总宽度超过 `maxFrozenWidth` 时,左侧冻结区域可横向滚动,默认为 `false`。开启后会保留全部冻结列,并在冻结区域内通过触摸板横向滚动或拖拽滚动条查看超出部分。 - `unfreezeAllOnExceedsMaxWidth`: 当列宽超过最大冻结宽度时,是否全部自动解冻,默认为 `true`。如果设置为false,则不会解冻全部列,而是根据 maxFrozenWidth 的值来决定最终解冻的列数。 以下是一个配置示例: @@ -36,6 +36,29 @@ const listTable = new ListTable({ 配置项如下: - `rightFrozenColCount`: 右侧冻结列数,默认为 0。 +- `maxRightFrozenWidth`: 右侧最大冻结宽度,固定值 or 百分比。默认与 `maxFrozenWidth` 一致。 +- `scrollRightFrozenCols`: 当右侧冻结列总宽度超过 `maxRightFrozenWidth` 时,右侧冻结区域可横向滚动,默认为 `false`。 + +## 冻结区域横向滚动 + +当冻结区域的“内容总宽度”超过最大冻结宽度时,可以开启冻结区域内部横向滚动来保留全部冻结列。 + +```javascript +const listTable = new ListTable({ + // ...其他配置项 + frozenColCount: 6, + maxFrozenWidth: 320, + scrollFrozenCols: true, + + rightFrozenColCount: 4, + maxRightFrozenWidth: 320, + scrollRightFrozenCols: true +}); +``` + +开启后: +- 左/右冻结区域会在自身区域内响应触摸板横向滚动。 +- 当滚动条可见时,底部会出现对应冻结区域的独立横向滚动条。 ## 顶部冻结行 diff --git a/docs/assets/guide/zh/interaction/scroll.md b/docs/assets/guide/zh/interaction/scroll.md index c966990fff..38e9877677 100644 --- a/docs/assets/guide/zh/interaction/scroll.md +++ b/docs/assets/guide/zh/interaction/scroll.md @@ -21,6 +21,12 @@ VTable 提供了丰富的滚动样式配置项,用户可以按照自己的需 - barToSide :是否显示到容器的边缘 尽管内容没有撑满的情况下. 默认 false - ignoreFrozenCols :横向滚动条是否忽略冻结列宽度,默认false +当表格启用了冻结区域内部横向滚动(如 `scrollFrozenCols` / `scrollRightFrozenCols`)时,表格横向会同时存在多个可滚动区域(body / 左冻结 / 右冻结)。此时: + +- `visible: 'always'`:所有区域的滚动条都会常驻显示(若该区域确实可滚动)。 +- `visible: 'scrolling'`:仅在“滚动发生”或“鼠标悬停在滚动条区域”时显示,离开后会自动隐藏。 +- `visible: 'focus'`:鼠标指针聚焦到某个区域时,仅显示该区域对应的滚动条(避免多个滚动条同时展示)。 + 下面我们通过示例来展示这些配置的效果: ```javascript livedemo template=vtable @@ -109,6 +115,35 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American VTable 支持在按住 Shift 键时进行横向滚动,或者直接拖拽横向滚动条,以便用户更方便地浏览表格数据。当然,如果您的电脑有触摸板,可以直接在触摸板上直接左右滑动来实现横向滚动。 +## 冻结区域内横向滚动 + +在开启列冻结后,如果冻结区域的“冻结列总宽度”超过最大冻结宽度(`maxFrozenWidth` / `maxRightFrozenWidth`),除了将冻结列自动解冻以适配可视宽度外,也可以开启冻结区域内部横向滚动来“保留全部冻结列”: + +- 左侧冻结区域:通过 `scrollFrozenCols: true` 开启 +- 右侧冻结区域:通过 `scrollRightFrozenCols: true` 开启 + +开启后,触摸板的横向滚动会在对应冻结区域内生效(无需先滚动 body),并且在滚动条可见时底部会出现对应冻结区域的独立横向滚动条,支持拖拽滑块或点击轨道进行滚动。 + +默认情况下(`scrollFrozenColsPassThroughToBody: false`),在冻结区域滚动到头/尾时不会联动滚动 body;如希望在冻结区域到边界后继续滚动 body,可开启该配置。 + +示例: + +```javascript +const option = { + // ...其他配置项 + frozenColCount: 6, + maxFrozenWidth: 320, + scrollFrozenCols: true, + + rightFrozenColCount: 4, + maxRightFrozenWidth: 320, + scrollRightFrozenCols: true, + scrollFrozenColsPassThroughToBody: false +}; +``` + +更多冻结相关配置可参考《行列冻结》章节。 + ## 滚动接口 VTable 提供了 scrollToCell 接口,用于滚到指定的单元格位置。该方法接受 cellAddr 参数用于指定要滚动到的单元位置。示例代码如下: diff --git a/docs/assets/option/en/common/option-secondary.md b/docs/assets/option/en/common/option-secondary.md index bc439188e8..abc43c2089 100644 --- a/docs/assets/option/en/common/option-secondary.md +++ b/docs/assets/option/en/common/option-secondary.md @@ -104,6 +104,22 @@ number of frozen rows at the bottom Maximum freezing width, fixed value or percentage. Default is '80%' +#${prefix} scrollFrozenCols(boolean) = false + +When the total width of frozen columns exceeds maxFrozenWidth, the frozen area becomes horizontally scrollable. When enabled, all frozen columns are kept and you can use trackpad horizontal scrolling or drag the scrollbar inside the frozen area to view the overflow part. + +#${prefix} maxRightFrozenWidth(number | string) = maxFrozenWidth + +Maximum freeze width for right frozen columns (fixed value or percentage). Defaults to maxFrozenWidth. + +#${prefix} scrollRightFrozenCols(boolean) = false + +When the total width of right frozen columns exceeds maxRightFrozenWidth, the right frozen area becomes horizontally scrollable. When enabled, all right frozen columns are kept and you can use trackpad horizontal scrolling or drag the scrollbar inside the frozen area to view the overflow part. + +#${prefix} scrollFrozenColsPassThroughToBody(boolean) = false + +When horizontally scrolling inside a frozen area (left frozen / right frozen) reaches its boundary, whether to pass the scroll intent through to the body horizontal scrolling. Default is false, which means the body will not scroll when the frozen area is already at its start/end. When enabled, body horizontal scrolling will be triggered if the frozen area can no longer scroll. + #${prefix} unfreezeAllOnExceedsMaxWidth(boolean) = true Whether to defrost after the maximum freezing width is exceeded. The default value is true. If set to false, it will not unfreeze all columns, but will determine the number of columns to be unfrozen according to the value of maxFrozenWidth. @@ -762,4 +778,4 @@ Component layout order, default is ['legend', 'title']. ``` componentLayoutOrder?: ('legend' | 'title')[]; -``` \ No newline at end of file +``` diff --git a/docs/assets/option/zh/common/option-secondary.md b/docs/assets/option/zh/common/option-secondary.md index d6bf094059..cc560547bf 100644 --- a/docs/assets/option/zh/common/option-secondary.md +++ b/docs/assets/option/zh/common/option-secondary.md @@ -108,6 +108,22 @@ containerFit: { 最大冻结宽度,固定值 or 百分比。默认为'80%' +#${prefix} scrollFrozenCols(boolean) = false + +当冻结列总宽度超过 maxFrozenWidth 时,冻结区域可横向滚动。开启后会保留全部冻结列,并在冻结区域内通过触摸板横向滚动或拖拽滚动条查看超出部分。 + +#${prefix} maxRightFrozenWidth(number | string) = maxFrozenWidth + +右侧最大冻结宽度,固定值 or 百分比。默认与 maxFrozenWidth 一致。 + +#${prefix} scrollRightFrozenCols(boolean) = false + +当右侧冻结列总宽度超过 maxRightFrozenWidth 时,右侧冻结区域可横向滚动。开启后会保留全部右侧冻结列,并在冻结区域内通过触摸板横向滚动或拖拽滚动条查看超出部分。 + +#${prefix} scrollFrozenColsPassThroughToBody(boolean) = false + +当在冻结区域(左冻结/右冻结)内横向滚动到边界时,是否将滚动意图“透传”给 body 横向滚动。默认 false,即在冻结区域滚动到头/尾也不会触发 body 滚动;开启后在冻结区域无法继续滚动时会自动联动滚动 body。 + #${prefix} unfreezeAllOnExceedsMaxWidth(boolean) = true 超过最大冻结宽度后是否全部解冻,默认 true。如果设置为 false,则不会解冻全部列,而是根据 maxFrozenWidth 的值来决定最终解冻的列数。 @@ -758,4 +774,4 @@ defaultCursor?: 'default' | 'cell' | 'pointer' | 'text' | 'wait' | 'help' | 'cro ``` componentLayoutOrder?: ('legend' | 'title')[]; -``` \ No newline at end of file +``` diff --git a/packages/vtable-plugins/demo/auto-fill/auto-fill.ts b/packages/vtable-plugins/demo/auto-fill/auto-fill.ts index 1b465791a0..45ef83ea91 100644 --- a/packages/vtable-plugins/demo/auto-fill/auto-fill.ts +++ b/packages/vtable-plugins/demo/auto-fill/auto-fill.ts @@ -75,6 +75,36 @@ export function createTable() { field: 'otherDirection_2', title: '', width: 120 + }, + { + field: 'id', + title: 'ID', + width: 80 + }, + { + field: 'name', + title: '章节', + width: 150 + }, + { + field: 'arithmetic', + title: '等差', + width: 120 + }, + { + field: 'geometric', + title: '等比', + width: 120 + }, + { + field: 'date', + title: '日期', + width: 120 + }, + { + field: 'week', + title: '星期', + width: 120 } ]; @@ -93,6 +123,9 @@ export function createTable() { excelOptions: { fillHandle: true // 启用填充炳功能 }, + frozenColCount: 4, + maxFrozenWidth: 320, + scrollFrozenCols: true, plugins: [autoFillPlugin] }; diff --git a/packages/vtable/docs/frozen-area-scroll-design.md b/packages/vtable/docs/frozen-area-scroll-design.md new file mode 100644 index 0000000000..a468ee9e2c --- /dev/null +++ b/packages/vtable/docs/frozen-area-scroll-design.md @@ -0,0 +1,176 @@ +# 冻结区域独立横向滚动:技术设计 + +本文总结 VTable 中“冻结区域(左冻结/右冻结)支持独立横向滚动”的实现设计、核心数据结构、关键链路改动点与边界行为。 + +> 对应实现主要落在 packages/vtable/src 内;PR 参考:#5063(Feat/frozen column scroll)。 + +## 1. 背景与问题 + +在 ListTable 中,冻结列用于保持关键列常驻可见。但当冻结列总宽度超过最大冻结宽度(`maxFrozenWidth`)时,既有策略通常是“自动解冻部分列”以满足视口宽度限制。 + +在一些业务场景中,冻结列必须保留(例如左侧关键标识列、右侧操作列),因此需要: + +- 冻结区域在宽度受限时仍保留全部冻结列 +- 在冻结区域内部提供横向滚动能力(与 body 横向滚动相互独立) +- 在多滚动域场景下,滚动条显隐与交互需要“按区域”工作,避免混乱 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 新增左/右冻结区域内部横向滚动能力(trackpad/wheel 与滚动条拖拽/点击轨道) +- 保持冻结区域“视口宽度”可控(左:`maxFrozenWidth`,右:`maxRightFrozenWidth`) +- 多滚动域并存时(body / leftFrozen / rightFrozen),保证: + - 渲染坐标系正确(内容随各自 scrollLeft 平移) + - 命中坐标正确(getCellAt、事件 target 等与渲染一致) + - 主滚动条与冻结滚动条互不干扰 + - `scrollStyle.visible` 为 `focus/scrolling/always/none` 时行为一致 + +### 2.2 非目标 + +- 本设计不覆盖 PivotTable/Gantt 的冻结滚动扩展(当前以 ListTable 为主) +- 不引入插件化实现(该能力需要穿透状态/布局/命中/scenegraph 多层链路) + +## 3. 概念与术语 + +- **冻结内容宽(content width)**:冻结列本身的总宽度,不受 maxFrozenWidth 限制。 +- **冻结视口宽(viewport width)**:冻结区域在画布上占用的宽度,受 maxFrozenWidth / maxRightFrozenWidth 限制。 +- **冻结溢出量(offset)**:`max(0, contentWidth - viewportWidth)`,表示冻结区域内部最大可滚动距离。 +- **滚动域(scroll domain)**: + - `body`:主横向滚动域(影响非冻结列) + - `frozen`:左冻结区域内部横向滚动域 + - `rightFrozen`:右冻结区域内部横向滚动域 + +## 4. 对外配置与 API + +### 4.1 新增配置(ListTableConstructorOptions) + +- `scrollFrozenCols?: boolean` + - `false`(默认):冻结列超出最大冻结宽度时遵循原策略(可能解冻) + - `true`:冻结区域内部可横向滚动,保留全部冻结列 +- `maxRightFrozenWidth?: number | string` + - 右侧最大冻结宽度,默认与 `maxFrozenWidth` 对齐 +- `scrollRightFrozenCols?: boolean` + - `false`(默认):右冻结区域宽度 = 内容宽度(无内部滚动) + - `true`:右冻结区域内部可横向滚动 + +### 4.2 相关方法(BaseTable) + +左冻结: + +- `getFrozenColsContentWidth()`:冻结内容宽 +- `getFrozenColsWidth()`:冻结视口宽(scrollFrozenCols 开启时受 maxFrozenWidth 限制) +- `getFrozenColsOffset()`:溢出量(最大可滚动距离) +- `getFrozenColsScrollLeft()`:当前左冻结 scrollLeft(px) + +右冻结: + +- `getRightFrozenColsContentWidth()` +- `getRightFrozenColsWidth()`(scrollRightFrozenCols 开启时受 maxRightFrozenWidth 限制) +- `getRightFrozenColsOffset()` +- `getRightFrozenColsScrollLeft()` + +## 5. 数据结构(StateManager) + +在 StateManager 中新增两个横向位置用于维护冻结域滚动: + +- `scroll.frozenHorizontalBarPos`:左冻结 scrollLeft(px) +- `scroll.rightFrozenHorizontalBarPos`:右冻结 scrollLeft(px) + +并提供两类接口: + +1) 外部“设置滚动位置”(用于 wheel / click 轨道 / 拖拽) +- `setFrozenColsScrollLeft(left, triggerRender?)` +- `setRightFrozenColsScrollLeft(left, triggerRender?)` + +2) 外部“按滚动条 ratio 更新”(scrollDrag 回调给的是 range,需要映射回 scrollLeft) +- `updateFrozenHorizontalScrollBar(xRatio)` +- `updateRightFrozenHorizontalScrollBar(xRatio)` + +右冻结的 ratio 与 left 做了反向映射(`ratio = 1 - left/maxScrollLeft`),以更符合“右冻结内容从右向左展开”的视觉直觉。 + +## 6. 渲染与布局(Scenegraph) + +### 6.1 左冻结 + +左冻结的平移相对直观:冻结区域内部滚动时,对应 group 的 childrenX 直接使用 `-scrollLeft`。 + +### 6.2 右冻结 + +右冻结的布局基准是“内容右对齐视口”,因此需要同时考虑溢出量 offset 与 scrollLeft: + +- `rightFrozenStartX = -rightFrozenOffset + rightFrozenScrollLeft` + +含义: + +- `-offset`:使右冻结内容尾部对齐到视口右侧(把超出部分整体向左移出视口) +- `+scrollLeft`:在视口内左右移动查看隐藏的列 + +对应更新点: + +- `Scenegraph.updateContainerAttrWidthAndX()` 在布局刷新时更新 rightFrozenGroup / corner group 的 childrenX +- `Scenegraph.setRightFrozenColsScrollLeft()` 在右冻结滚动变化时更新 rightFrozenGroup / rightTopCorner / rightBottomCorner 的 childrenX + +### 6.3 Clip(裁剪) + +多区域 overlay/内容组均依赖 clipRect 进行裁剪。右冻结视口宽度在开启 scrollRightFrozenCols 时不再等于内容宽度,需要使用 `getRightFrozenColsWidth()` 作为 clip 宽度来源,保证“内容滚动但不越界绘制”。 + +## 7. 命中与坐标映射(HitTest) + +冻结区域内部滚动会改变“可视坐标 ↔ 内容坐标”的映射关系,因此需要在命中链路中补偿: + +- 右冻结命中:当 x 落在右冻结视口范围内时,先将 `absoluteX -= rightFrozenScrollLeft` 再计算 target col +- 右冻结列 x 计算:`getColX(col, table, true)` 叠加 `getRightFrozenColsScrollLeft()`,保证渲染坐标与 hitTest 一致 + +## 8. 事件分发(Wheel/Trackpad) + +横向 wheel 需要判断“滚动意图属于哪个滚动域”: + +- 优先冻结域:当指针坐标落在左冻结/右冻结视口范围内且该域可滚动(offset>0) +- 否则落入 body 域 + +注意:部分环境 wheel 事件可能没有可靠的 x/y,因此引入 LastBodyPointerXY 作为回退坐标。 + +右冻结域的 delta 需要反向映射: + +- `rightFrozenDelta = -optimizedDeltaX` + +原因是右冻结内容的“展开方向”与 body/左冻结相反(内容从右向左展开)。 + +## 9. 滚动条系统(ScrollBar UI) + +### 9.1 多段横向滚动条 + +当左右冻结域启用内部滚动且存在溢出时,底部会出现三段横向滚动条: + +- body 主滚动条(hScrollBar) +- 左冻结横向滚动条(frozenHScrollBar) +- 右冻结横向滚动条(rightFrozenHScrollBar) + +各段的 range(滑块长度)分别反映其域的“视口宽 / 内容宽”。 + +### 9.2 显隐策略与交互 + +`scrollStyle.visible` 在多滚动域场景下的定义: + +- `always`:所有可滚动域的滚动条同时显示 +- `focus`:只显示指针所在域的滚动条(避免干扰) +- `scrolling`:滚动发生时显示;hover 到滚动条区域时显示以支持交互;离开后延迟隐藏 + +实现上通过 `TableComponent.showHorizontalScrollBar(target)` 控制显示目标域,并在事件监听中根据 hover/scrolling 规则维护 autoHide。 + +## 10. 关键边界与已处理问题 + +- **右冻结分割线(shadow line)错位**:当右冻结内容可滚动时,分割线应固定在“右冻结视口左边界”而不是随内容滚动 +- **选区 overlay 被裁切**:当选区贴边或存在 fill handle 时,需要对 overlay 的 clipRect 进行外扩(详见选框技术设计文档) +- **拖拽滚动条不生效**:throttle 绑定函数需要 bind(this),否则 this.table 不可用 + +## 11. 可观测性与测试建议 + +建议覆盖以下用例: + +- 左冻结溢出:trackpad 横向滚动仅影响左冻结内容;body 不动 +- 右冻结溢出:trackpad 横向滚动方向符合预期;命中列与渲染一致 +- 滚动条:三段滚动条的滑块比例正确;拖拽与点击轨道能驱动对应域滚动 +- visible 策略:focus/scolling 下仅显示目标域滚动条且可自动隐藏 + diff --git a/packages/vtable/docs/select-border-design.md b/packages/vtable/docs/select-border-design.md new file mode 100644 index 0000000000..91eca726a8 --- /dev/null +++ b/packages/vtable/docs/select-border-design.md @@ -0,0 +1,171 @@ +# 选框(Select Border)改造:技术设计(前/后对比) + +本文总结 VTable 选框(selection border + fill handle)的设计背景、改造前的问题、改造后的结构与关键实现点,便于后续维护与扩展。 + +> 涉及代码:packages/vtable/src/scenegraph/select/* 及 overlay clip 相关贡献逻辑。 + +## 1. 背景 + +VTable 的“选框”由两个主要视觉元素组成: + +- **selection border**:选区外边框(可配置边框色、线宽、虚线等) +- **fill handle**:右下角 6x6 小方块(Excel 类填充柄),受 `excelOptions.fillHandle` 控制 + +在引入“冻结区域独立滚动”后,表格的可视区域被拆成多个区域(body/headers/leftFrozen/rightFrozen/bottomFrozen),选框需要: + +- 正确出现在对应区域的 overlay 层并被 clip 裁剪 +- 支持跨区域选择(需要拆分多段绘制) +- 避免在跨区域边界重复描边(导致边框变粗) +- 在贴边/裁剪边界时不出现“半条线”或 fill handle 被切掉 + +## 2. 改造前设计(概念层面) + +### 2.1 选框的基本组织方式 + +- 选框图元挂在 overlayGroup 下(select-overlay),overlayGroup 会被各区域 clipRect 裁剪。 +- 对于跨区域选区,会拆分为多个选框段落(每段一个 rect,必要时一个 fill handle)。 + +### 2.2 主要痛点 + +1) **贴边裁剪导致的边框缺失** +- 选框的描边线宽是以 rect 边界为中心绘制;当选区贴着表格边缘或区域 clip 边界时,一半线宽会被裁剪,呈现“半条线”。 + +2) **fill handle 被裁剪或定位异常** +- 在最后一行/最后一列等边界场景,fill handle 可能落到 clip 外,导致不可见或难以命中。 + +3) **跨区域选框的重复描边** +- 选区跨越多个 overlay(例如 columnHeader + body,或 body + rightFrozen)时,如果各段都绘制相邻边,会在边界处叠加出更粗的边框。 + +4) **场景树重建导致选区丢失** +- 数据更新导致 scenegraph 重建时,overlay 下的选区图元会被清空,需要从 state 恢复。 + +## 3. 改造后设计(实现层面) + +改造后的原则: + +- **跨区域必拆分**:按区域拆分为多段选框,每段只负责自己的绘制与裁剪 +- **边界不重复描边**:通过 strokeArray 控制每段四边是否绘制 +- **贴边不裁半线**:在选框更新时对 rect 做“半线宽补偿”,同时对 overlay clipRect 做“外扩” +- **fill handle 只在可解释场景出现**:单选区 + 非表头 + 边不被禁用时显示 + +## 4. 关键实现点 + +### 4.1 选框创建:overlay 坐标换算与 fill handle 显示条件 + +文件: + +- packages/vtable/src/scenegraph/select/create-select-border.ts + +要点: + +- 使用 `highPerformanceGetCell(...).globalAABBBounds` 获取单元格全局边界,再减去 `tableGroup + overlayGroup` 的偏移,换算成 overlay 本地坐标。 +- fill handle 的显示需要满足: + - `excelOptions.fillHandle` 开启 + - 当前仅 1 个选区(多选区时移除所有 handle) + - 选区不包含表头(header 不允许填充) + - strokes 中右边或下边被关闭时不显示(避免 handle 由其它段负责时重复出现) + +### 4.2 选框更新:可视范围裁剪与 fill handle 边界定位 + +文件: + +- packages/vtable/src/scenegraph/select/update-select-border.ts + +要点: + +1) **按 role 裁剪计算范围** + +不同区域的选框更新策略不同: + +- `rowHeader`:只裁剪行范围(跟随 body 可视行) +- `columnHeader` / `bottomFrozen`:只裁剪列范围(跟随 body 可视列) +- `rightFrozen`:只裁剪行范围(跟随 body 可视行) +- `body`:裁剪行列范围 + +目的是避免更新不可见区域的选框段落,降低滚动过程的更新开销。 + +2) **fill handle 边界推导** + +当选区触达最后一列/最后一行时,直接取 “end cell bound” 可能导致 handle 超出 clip。实现上通过相邻单元格的 bound 推导 `handlerX/handlerY`,让 handle 保持在可见边界附近。 + +3) **贴边半线宽裁切修正** + +当选区贴着表格外边界时,通过根据 lineWidth 计算 diffSize,对 rect 做 x/y/width/height 的微调,避免“半条线”现象。 + +### 4.3 跨区域拆分:calculateCellRangeDistribution + strokeArray + +文件: + +- packages/vtable/src/scenegraph/select/update-select-border.ts +- packages/vtable/src/scenegraph/select/update-custom-select-border.ts + +流程: + +1) `calculateCellRangeDistribution(startCol, startRow, endCol, endRow, table)` 判断选区跨越哪些区域。 +2) 针对每个需要的区域创建一段选框,传入该段负责的范围。 +3) 通过 `strokeArray=[top,right,bottom,left]` 控制该段四边是否绘制,避免跨区域边界重复描边。 + +自定义选框(CustomSelectionStyle)沿用相同的拆分策略,但只绘制 rect,不包含 fill handle。 + +### 4.4 selecting → selected 的提交语义 + +文件: + +- packages/vtable/src/scenegraph/select/move-select-border.ts + +语义: + +- `selectingRangeComponents` 表示“拖拽中”的临时选框 +- 鼠标松开后需要迁移到 `selectedRangeComponents`,作为稳定的选中态 +- 若同 key 已存在历史段落,先 delete 避免泄漏与重复绘制 + +### 4.5 删除逻辑:shift 续选与 fill handle 清理 + +文件: + +- packages/vtable/src/scenegraph/select/delete-select-border.ts + +要点: + +- 通过 `scene.lastSelectId` 识别“上一次选择动作”产生的所有段落(跨区域拆分时 selectId 相同) +- shift 续选需要删除上一次选择段落,再追加新的段落 +- 多选区时需要统一移除 fill handle + +### 4.6 overlay 裁剪外扩:避免边框与 fill handle 被 clip 截断 + +文件: + +- packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts + +要点: + +- overlay(select-overlay)组会被各区域 clipRect 裁剪 +- 对 overlay 的 clipRect 进行“外扩”(inflate): + - baseInflate:覆盖 selection border 的线宽 + - handleInflate:当开启 fill handle 且只有一个选区时,为 6x6 handle 预留空间(3px) + +这一层和 4.2 的贴边修正配合,解决“线宽/handle 被裁切”的可见性问题。 + +### 4.7 场景树重建后的选区恢复 + +文件: + +- packages/vtable/src/scenegraph/scenegraph.ts + +要点: + +- 数据更新触发 scenegraph 重建会清空 overlay 下的选区图元 +- 若 state 中仍存在 select ranges,需要在场景树重建完成后重新创建选区组件,保证选中态不丢失 + +## 5. 行为对比(摘要) + +- 改造前:选区贴边时边框/handle 可能被裁,跨区域容易重复描边,scenegraph 重建后可能丢失选区图元。 +- 改造后:通过“跨区域拆分 + strokeArray 去重 + overlay clip 外扩 + 贴边半线宽修正 + 重建后恢复”保证一致性与可维护性。 + +## 6. 测试建议 + +- 单选区 + fill handle:拖拽到最后一行/最后一列仍可见且可命中 +- 多选区:fill handle 不出现,且历史 handle 会被清理 +- 跨区域选择:跨表头/左冻结/右冻结/底部冻结时边框不加粗 +- scrollFrozenCols/scrollRightFrozenCols 开启:滚动冻结域时选框与 handle 不被裁切、不漂移 + diff --git a/packages/vtable/examples/frozen/list-table-scrollable-frozen-cols.ts b/packages/vtable/examples/frozen/list-table-scrollable-frozen-cols.ts new file mode 100644 index 0000000000..9afcba0f68 --- /dev/null +++ b/packages/vtable/examples/frozen/list-table-scrollable-frozen-cols.ts @@ -0,0 +1,84 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +const generatePersons = (count: number) => { + return Array.from(new Array(count)).map((_, i) => ({ + id: i + 1, + email1: `${i + 1}@xxx.com`, + name: `小明${i + 1}`, + lastName: '王', + date1: '2022年9月1日', + tel: '000-0000-0000', + sex: i % 2 === 0 ? 'boy' : 'girl', + work: i % 2 === 0 ? 'back-end engineer' : 'front-end engineer', + city: 'beijing' + })); +}; + +export function createTable() { + const records = generatePersons(100); + const columns: VTable.ColumnsDefine = [ + { field: 'id', title: 'ID', width: 120, sort: true }, + { field: 'email1', title: 'email', width: 220, sort: true }, + { field: 'name', title: 'First Name', width: 160 }, + { field: 'lastName', title: 'Last Name', width: 160 }, + { field: 'date1', title: 'birthday', width: 160 }, + { field: 'sex', title: 'sex', width: 120 }, + { field: 'tel', title: 'telephone', width: 180 }, + { field: 'work', title: 'job', width: 200 }, + { field: 'city', title: 'city', width: 160 }, + { field: 'work', title: 'job2', width: 200 }, + { field: 'city', title: 'city2', width: 160 }, + { field: 'tel', title: 'telephone2', width: 180 }, + { field: 'work', title: 'job', width: 200 }, + { field: 'city', title: 'city', width: 160 }, + { field: 'work', title: 'job2', width: 200 }, + { field: 'city', title: 'city2', width: 160 }, + { field: 'tel', title: 'telephone2', width: 180 } + ]; + + const option: VTable.TYPES.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records, + columns, + frozenColCount: 6, + rightFrozenColCount: 4, + maxFrozenWidth: 320, + scrollFrozenCols: true, + maxRightFrozenWidth: 320, + scrollRightFrozenCols: true, + scrollFrozenColsPassThroughToBody: true, + theme: VTable.themes.DEFAULT.extends({ + scrollStyle: { + visible: 'scrolling' + } + }), + excelOptions: { + fillHandle: args => { + const { selectRanges, table } = args; + if (selectRanges.length === 1) { + const { start, end } = selectRanges[0]; + console.log('fillHandle', start, end); + const minCol = Math.min(start.col, end.col); + const maxCol = Math.max(start.col, end.col); + const minRow = Math.min(start.row, end.row); + const maxRow = Math.max(start.row, end.row); + //判断start到end 所有单元格有没有不能编辑的 + for (let col = minCol; col <= maxCol; col++) { + for (let row = minRow; row <= maxRow; row++) { + if (row === 2 && col === 2) { + return false; + } + } + } + return true; + } + return false; + } + } + }; + + const tableInstance = new VTable.ListTable(option); + (window as any).tableInstance = tableInstance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 855c295685..658a0acdf2 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -1111,6 +1111,10 @@ export const menus = [ path: 'frozen', name: 'list-table-scrollx-skip-frozen' }, + { + path: 'frozen', + name: 'list-table-scrollable-frozen-cols' + }, { path: 'frozen', name: 'pivot-table-right-frozen' diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index ab99553bb2..42f3169dd5 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -774,7 +774,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { // 纠正frozenColCount的值; const maxFrozenWidth = this._getMaxFrozenWidth(); // if (this.tableNoFrameWidth - this.getColsWidth(0, frozenColCount - 1) <= 120) { - if (this.getColsWidth(0, frozenColCount - 1) > maxFrozenWidth) { + if (!this.options.scrollFrozenCols && this.getColsWidth(0, frozenColCount - 1) > maxFrozenWidth) { if (this.internalProps.unfreezeAllOnExceedsMaxWidth) { this.internalProps.frozenColCount = 0; } else { @@ -795,7 +795,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { //纠正frozenColCount的值 const maxFrozenWidth = this._getMaxFrozenWidth(); // if (this.tableNoFrameWidth - this.getColsWidth(0, frozenColCount - 1) <= 120) { - if (this.getColsWidth(0, frozenColCount - 1) > maxFrozenWidth) { + if (!this.options.scrollFrozenCols && this.getColsWidth(0, frozenColCount - 1) > maxFrozenWidth) { if (this.internalProps.unfreezeAllOnExceedsMaxWidth) { this.internalProps.frozenColCount = 0; } else { @@ -1088,6 +1088,10 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { const maxFrozenWidth = this.options.maxFrozenWidth ?? '80%'; return _toPxWidth(this, maxFrozenWidth); } + _getMaxRightFrozenWidth(): number { + const maxRightFrozenWidth = this.options.maxRightFrozenWidth ?? this.options.maxFrozenWidth ?? '80%'; + return _toPxWidth(this, maxRightFrozenWidth); + } _getComputedFrozenColCount(frozenColCount: number): number { const maxFrozenWidth = this._getMaxFrozenWidth(); let computedfrozenColCount = frozenColCount; @@ -2079,7 +2083,11 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { relativeY = false; } const cellRect = this.getCellRect(col, row); - return this._toRelativeRect(cellRect, relativeX, relativeY); + const rect = this._toRelativeRect(cellRect, relativeX, relativeY); + if (isFrozenCell?.col && !this.isRightFrozenColumn(col, row)) { + rect.offsetLeft(-this.getFrozenColsScrollLeft()); + } + return rect; } /** * 获取的位置是相对表格显示界面的左上角 @@ -2099,7 +2107,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } else if (isFrozenCell?.row) { relativeY = false; } - return this._toRelativeRect( + const rect = this._toRelativeRect( this.getCellsRect( (range).start.col, (range).start.row, @@ -2109,6 +2117,10 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { relativeX, relativeY ); + if (isFrozenCell?.col && !this.isRightFrozenColumn((range).start.col, (range).start.row)) { + rect.offsetLeft(-this.getFrozenColsScrollLeft()); + } + return rect; } const cellRange = this.getCellRange((range).col, (range).row); const isFrozenCell = this.isFrozenCell((range).col, (range).row); @@ -2122,11 +2134,15 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } else if (isFrozenCell?.row) { relativeY = false; } - return this._toRelativeRect( + const rect = this._toRelativeRect( this.getCellsRect(cellRange.start.col, cellRange.start.row, cellRange.end.col, cellRange.end.row), relativeX, relativeY ); + if (isFrozenCell?.col && !this.isRightFrozenColumn((range).col, (range).row)) { + rect.offsetLeft(-this.getFrozenColsScrollLeft()); + } + return rect; } /** * 即仅视觉看到的位置 获取的位置是相对表格显示界面的左上角 @@ -2429,7 +2445,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { const { scrollTop, scrollLeft } = this; const width = this.tableNoFrameWidth; const height = this.tableNoFrameHeight; - return new Rect(scrollLeft, scrollTop, width, height); + return new Rect(scrollLeft + this.getFrozenColsOffset(), scrollTop, width, height); } /** * 获取网格中完全可见的可滚动行数。不包括表头及冻结的行 @@ -2464,19 +2480,20 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { getBodyVisibleCellRange() { const { scrollTop, scrollLeft } = this; const frozenRowsHeight = this.getFrozenRowsHeight(); - const frozenColsWidth = this.getFrozenColsWidth(); + const frozenColsContentWidth = this.getFrozenColsContentWidth(); + const frozenColsOffset = this.getFrozenColsOffset(); const bottomFrozenRowsHeight = this.getBottomFrozenRowsHeight(); const rightFrozenColsWidth = this.getRightFrozenColsWidth(); // 计算非冻结 const { row: rowStart } = this.getRowAt(scrollTop + frozenRowsHeight + 1); - const { col: colStart } = this.getColAt(scrollLeft + frozenColsWidth + 1); + const { col: colStart } = this.getColAt(scrollLeft + frozenColsContentWidth + 1); const rowEnd = this.getAllRowsHeight() > this.tableNoFrameHeight ? this.getRowAt(scrollTop + this.tableNoFrameHeight - 1 - bottomFrozenRowsHeight).row : this.rowCount - 1; const colEnd = this.getAllColsWidth() > this.tableNoFrameWidth - ? this.getColAt(scrollLeft + this.tableNoFrameWidth - 1 - rightFrozenColsWidth).col + ? this.getColAt(scrollLeft + frozenColsOffset + this.tableNoFrameWidth - 1 - rightFrozenColsWidth).col : this.colCount - 1; if (colEnd < 0 || rowEnd < 0) { return null; @@ -2512,14 +2529,16 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { */ getBodyVisibleColRange(start_deltaX: number = 0, end_deltaX: number = 0) { const { scrollLeft } = this; - const frozenColsWidth = this.getFrozenColsWidth(); + const frozenColsContentWidth = this.getFrozenColsContentWidth(); + const frozenColsOffset = this.getFrozenColsOffset(); const rightFrozenColsWidth = this.getRightFrozenColsWidth(); // 计算非冻结 - const { col: colStart } = this.getColAt(scrollLeft + frozenColsWidth + 1 + start_deltaX); + const { col: colStart } = this.getColAt(scrollLeft + frozenColsContentWidth + 1 + start_deltaX); const colEnd = this.getAllColsWidth() > this.tableNoFrameWidth - ? this.getColAt(scrollLeft + this.tableNoFrameWidth - 1 - rightFrozenColsWidth + end_deltaX).col + ? this.getColAt(scrollLeft + frozenColsOffset + this.tableNoFrameWidth - 1 - rightFrozenColsWidth + end_deltaX) + .col : this.colCount - 1; if (colEnd < 0) { return null; @@ -2533,8 +2552,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { get visibleColCount(): number { const { frozenColCount } = this; const visibleRect = this.getVisibleRect(); - const visibleLeft = - frozenColCount > 0 ? visibleRect.left + this.getColsWidth(0, frozenColCount - 1) : visibleRect.left; + const visibleLeft = frozenColCount > 0 ? visibleRect.left + this.getFrozenColsWidth() : visibleRect.left; const initCol = this.getTargetColAt(visibleLeft); if (!initCol) { @@ -2995,8 +3013,28 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { * @returns */ getFrozenColsWidth(): number { - const w = this.getColsWidth(0, this.frozenColCount - 1); - return w; + const contentWidth = this.getFrozenColsContentWidth(); + // frozenColsWidth 表示“冻结区域视口宽度”,可能小于冻结列内容总宽。 + // 当开启 scrollFrozenCols 时,冻结区域会限制到 maxFrozenWidth,并允许在冻结区域内部横向滚动来查看超出部分。 + if (!this.options.scrollFrozenCols) { + return contentWidth; + } + const maxFrozenWidth = this._getMaxFrozenWidth(); + return Math.min(contentWidth, maxFrozenWidth); + } + getFrozenColsContentWidth(): number { + // 冻结列内容总宽(不受 maxFrozenWidth 限制) + return this.getColsWidth(0, this.frozenColCount - 1); + } + getFrozenColsOffset(): number { + // 冻结区域可滚动的最大距离(内容宽 - 视口宽),用于计算滚动条范围与边界判断 + const contentWidth = this.getFrozenColsContentWidth(); + const viewportWidth = this.getFrozenColsWidth(); + return Math.max(0, contentWidth - viewportWidth); + } + getFrozenColsScrollLeft(): number { + // 左冻结区域内部的横向滚动位置(像素值) + return this.stateManager.scroll.frozenHorizontalBarPos ?? 0; } /** * 获取底部冻结固定列总宽 @@ -3018,8 +3056,17 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { * @returns */ getRightFrozenColsWidth(): number { + const contentWidth = this.getRightFrozenColsContentWidth(); + // rightFrozenColsWidth 表示“右侧冻结区域视口宽度”。 + // 当开启 scrollRightFrozenCols 时,右侧冻结区域会限制到 maxRightFrozenWidth,并允许在右冻结区域内部横向滚动。 + if (!this.options.scrollRightFrozenCols) { + return contentWidth; + } + const maxRightFrozenWidth = this._getMaxRightFrozenWidth(); + return Math.min(contentWidth, maxRightFrozenWidth); + } + getRightFrozenColsContentWidth(): number { if (this.rightFrozenColCount > 0) { - // const width = this.getColsWidth(this.colCount - this.rightFrozenColCount, this.colCount - 1); // 同getBottomFrozenRowsHeight的原因 let width = 0; for (let col = this.colCount - this.rightFrozenColCount; col <= this.colCount - 1; col++) { width += this.getColWidth(col); @@ -3028,6 +3075,16 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { } return 0; } + getRightFrozenColsOffset(): number { + // 右侧冻结区域可滚动的最大距离(内容宽 - 视口宽) + const contentWidth = this.getRightFrozenColsContentWidth(); + const viewportWidth = this.getRightFrozenColsWidth(); + return Math.max(0, contentWidth - viewportWidth); + } + getRightFrozenColsScrollLeft(): number { + // 右冻结区域内部的横向滚动位置(像素值) + return this.stateManager.scroll.rightFrozenHorizontalBarPos ?? 0; + } /** * 获取实际绘制范围的宽高,而非可绘制画布大小 * @param table @@ -5192,7 +5249,10 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { // This ensures dynamically-computed row heights (e.g. autoWrapText) are reflected // in the scroll position calculation. const top = this.rowHeightsMap.getSumInRange(0, cellAddr.row - 1); - this.scrollTop = Math.min(top - frozenHeight, this.rowHeightsMap.getSumInRange(0, this.rowCount - 1) - drawRange.height); + this.scrollTop = Math.min( + top - frozenHeight, + this.rowHeightsMap.getSumInRange(0, this.rowCount - 1) - drawRange.height + ); } this.render(); } diff --git a/packages/vtable/src/core/tableHelper.ts b/packages/vtable/src/core/tableHelper.ts index d146a1ce41..3cb0779964 100644 --- a/packages/vtable/src/core/tableHelper.ts +++ b/packages/vtable/src/core/tableHelper.ts @@ -215,10 +215,12 @@ export function isAutoDefine(width: string | number): width is 'auto' { export function _getScrollableVisibleRect(table: BaseTableAPI): Rect { let frozenColsWidth = 0; + let frozenColsContentWidth = 0; let rightFrozenColsWidth = 0; if (table.frozenColCount > 0) { //有固定列时绘制固定列 frozenColsWidth = table.getFrozenColsWidth(); + frozenColsContentWidth = table.getFrozenColsContentWidth?.() ?? frozenColsWidth; } if (table.rightFrozenColCount > 0) { //有固定列时绘制固定列 @@ -235,7 +237,7 @@ export function _getScrollableVisibleRect(table: BaseTableAPI): Rect { bottomFrozenRowsHeight = table.getBottomFrozenRowsHeight(); } return new Rect( - table.scrollLeft + frozenColsWidth, + table.scrollLeft + frozenColsContentWidth, table.scrollTop + frozenRowsHeight, table.tableNoFrameWidth - frozenColsWidth - rightFrozenColsWidth, table.tableNoFrameHeight - frozenRowsHeight - bottomFrozenRowsHeight diff --git a/packages/vtable/src/core/utils/get-cell-position.ts b/packages/vtable/src/core/utils/get-cell-position.ts index ac48ab8da0..618b0aaaec 100644 --- a/packages/vtable/src/core/utils/get-cell-position.ts +++ b/packages/vtable/src/core/utils/get-cell-position.ts @@ -183,12 +183,16 @@ export function getTargetColAtConsiderRightFrozen( return { left: 0, col: 0, right: 0, width: 0 }; } absoluteX = absoluteX - _this.tableX; + const rightFrozenScrollLeft = _this.getRightFrozenColsScrollLeft?.() ?? 0; if ( isConsider && absoluteX > _this.tableNoFrameWidth - _this.getRightFrozenColsWidth() && absoluteX < _this.tableNoFrameWidth && absoluteX <= _this.getAllColsWidth() ) { + // 命中测试需要将“右冻结视口坐标”映射到“右冻结内容坐标”: + // 右冻结内容在视口内可滚动时,实际的列位置会整体随 scrollLeft 平移。 + absoluteX -= rightFrozenScrollLeft; for (let i = 0; i < _this.rightFrozenColCount; i++) { if (absoluteX > _this.tableNoFrameWidth - _this.getColsWidth(_this.colCount - i - 1, _this.colCount - 1)) { return { @@ -305,6 +309,10 @@ export function getCellAtRelativePosition(x: number, y: number, _this: BaseTable x -= _this.tableX; y -= _this.tableY; + const frozenColsWidth = _this.getFrozenColsWidth(); + const frozenColsOffset = _this.getFrozenColsOffset?.() ?? 0; + const frozenColsScrollLeft = _this.getFrozenColsScrollLeft?.() ?? 0; + // top frozen let topFrozen = false; if (y > 0 && y < _this.getFrozenRowsHeight()) { @@ -313,7 +321,7 @@ export function getCellAtRelativePosition(x: number, y: number, _this: BaseTable // left frozen let leftFrozen = false; - if (x > 0 && x < _this.getFrozenColsWidth()) { + if (x > 0 && x < frozenColsWidth) { leftFrozen = true; } @@ -338,7 +346,7 @@ export function getCellAtRelativePosition(x: number, y: number, _this: BaseTable // 加上 tableX 和 tableY 是因为在考虑冻结列和冻结行时,需要将坐标转换为相对于表格左上角的坐标 const colInfo = getTargetColAtConsiderRightFrozen( - (leftFrozen || rightFrozen ? x : x + _this.scrollLeft) + _this.tableX, + (leftFrozen ? x + frozenColsScrollLeft : rightFrozen ? x : x + _this.scrollLeft + frozenColsOffset) + _this.tableX, rightFrozen, _this ); @@ -372,9 +380,13 @@ export function getColAtRelativePosition(x: number, _this: BaseTableAPI): number // table border and outer component x -= _this.tableX; + const frozenColsWidth = _this.getFrozenColsWidth(); + const frozenColsOffset = _this.getFrozenColsOffset?.() ?? 0; + const frozenColsScrollLeft = _this.getFrozenColsScrollLeft?.() ?? 0; + // left frozen let leftFrozen = false; - if (x > 0 && x < _this.getFrozenColsWidth()) { + if (x > 0 && x < frozenColsWidth) { leftFrozen = true; } @@ -390,7 +402,7 @@ export function getColAtRelativePosition(x: number, _this: BaseTableAPI): number // 加上 tableX 是因为在考虑冻结列时,需要将坐标转换为相对于表格左上角的坐标 const colInfo = getTargetColAtConsiderRightFrozen( - (leftFrozen || rightFrozen ? x : x + _this.scrollLeft) + _this.tableX, + (leftFrozen ? x + frozenColsScrollLeft : rightFrozen ? x : x + _this.scrollLeft + frozenColsOffset) + _this.tableX, rightFrozen, _this ); diff --git a/packages/vtable/src/event/listener/container-dom.ts b/packages/vtable/src/event/listener/container-dom.ts index cf8e2625a3..269e5d0bda 100644 --- a/packages/vtable/src/event/listener/container-dom.ts +++ b/packages/vtable/src/event/listener/container-dom.ts @@ -571,6 +571,7 @@ export function bindContainerDomListener(eventManager: EventManager) { let selectX: number; let selectY: number; + const frozenOffset = table.getFrozenColsOffset?.() ?? 0; if (bottom) { selectY = table.scrollTop + drawRange.height - bottomFrozenRowHeight - 20; @@ -579,9 +580,9 @@ export function bindContainerDomListener(eventManager: EventManager) { } if (right) { - selectX = table.scrollLeft + drawRange.width - rightFrozenColsWidth - 20; + selectX = table.scrollLeft + frozenOffset + drawRange.width - rightFrozenColsWidth - 20; } else if (left) { - selectX = table.scrollLeft + leftFrozenColsWidth + 20; + selectX = table.scrollLeft + frozenOffset + leftFrozenColsWidth + 20; } let considerFrozenY = false; @@ -594,7 +595,7 @@ export function bindContainerDomListener(eventManager: EventManager) { selectX = x; considerFrozenX = true; } else { - selectX = table.scrollLeft + x; + selectX = table.scrollLeft + frozenOffset + x; } } if (!bottom && !top) { diff --git a/packages/vtable/src/event/listener/scroll-bar.ts b/packages/vtable/src/event/listener/scroll-bar.ts index 84f888e1f7..bc321cd1f9 100644 --- a/packages/vtable/src/event/listener/scroll-bar.ts +++ b/packages/vtable/src/event/listener/scroll-bar.ts @@ -11,25 +11,73 @@ export function bindScrollBarListener(eventManager: EventManager) { const table = eventManager.table; const stateManager = table.stateManager; const scenegraph = table.scenegraph; + const visible1 = table.theme.scrollStyle?.visible as string; + const horizontalVisible = table.theme.scrollStyle?.horizontalVisible ?? visible1; + const verticalVisible = table.theme.scrollStyle?.verticalVisible ?? visible1; - // 监听滚动条组件pointover事件 + // 监听滚动条 hover: + // - focus:hover 显示,pointerout 立即隐藏 + // - scrolling:hover 显示(用于支持“点轨道/拖拽”交互),pointerout 时重新触发 autoHide 计时 scenegraph.component.vScrollBar.addEventListener('pointerover', (e: any) => { - stateManager.showVerticalScrollBar(); + if (verticalVisible === 'focus' || verticalVisible === 'scrolling') { + stateManager.showVerticalScrollBar(); + } }); scenegraph.component.hScrollBar.addEventListener('pointerover', (e: any) => { - stateManager.showHorizontalScrollBar(); + if (horizontalVisible === 'focus' || horizontalVisible === 'scrolling') { + stateManager.showHorizontalScrollBar(false, 'body'); + } + }); + scenegraph.component.frozenHScrollBar.addEventListener('pointerover', (e: any) => { + if (horizontalVisible === 'focus' || horizontalVisible === 'scrolling') { + stateManager.showHorizontalScrollBar(false, 'frozen'); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointerover', (e: any) => { + if (horizontalVisible === 'focus' || horizontalVisible === 'scrolling') { + stateManager.showHorizontalScrollBar(false, 'rightFrozen'); + } }); scenegraph.component.vScrollBar.addEventListener('pointerout', (e: any) => { if (stateManager.interactionState === InteractionState.scrolling) { return; } - stateManager.hideVerticalScrollBar(); + if (verticalVisible === 'focus') { + stateManager.hideVerticalScrollBar(); + } else if (verticalVisible === 'scrolling') { + stateManager.showVerticalScrollBar(true); + } }); scenegraph.component.hScrollBar.addEventListener('pointerout', (e: any) => { if (stateManager.interactionState === InteractionState.scrolling) { return; } - stateManager.hideHorizontalScrollBar(); + if (horizontalVisible === 'focus') { + stateManager.hideHorizontalScrollBar(); + } else if (horizontalVisible === 'scrolling') { + // 鼠标离开滚动条后,滚动条应当回到“滚动时显示”的策略:延迟隐藏 + stateManager.showHorizontalScrollBar(true, 'body'); + } + }); + scenegraph.component.frozenHScrollBar.addEventListener('pointerout', (e: any) => { + if (stateManager.interactionState === InteractionState.scrolling) { + return; + } + if (horizontalVisible === 'focus') { + stateManager.hideHorizontalScrollBar(); + } else if (horizontalVisible === 'scrolling') { + stateManager.showHorizontalScrollBar(true, 'frozen'); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointerout', (e: any) => { + if (stateManager.interactionState === InteractionState.scrolling) { + return; + } + if (horizontalVisible === 'focus') { + stateManager.hideHorizontalScrollBar(); + } else if (horizontalVisible === 'scrolling') { + stateManager.showHorizontalScrollBar(true, 'rightFrozen'); + } }); scenegraph.component.vScrollBar.addEventListener('pointermove', (e: FederatedPointerEvent) => { scenegraph.table.stateManager.updateCursor('default'); @@ -78,6 +126,14 @@ export function bindScrollBarListener(eventManager: EventManager) { scenegraph.table.stateManager.updateCursor(); e.stopPropagation(); //防止冒泡到stage上 检测到挨着列间隔线判断成可拖拽 }); + scenegraph.component.frozenHScrollBar.addEventListener('pointermove', (e: FederatedPointerEvent) => { + scenegraph.table.stateManager.updateCursor(); + e.stopPropagation(); + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointermove', (e: FederatedPointerEvent) => { + scenegraph.table.stateManager.updateCursor(); + e.stopPropagation(); + }); scenegraph.component.hScrollBar.addEventListener('pointerdown', (e: FederatedPointerEvent) => { e.stopPropagation(); //防止冒泡到stage上 检测到挨着列间隔线判断成拖拽状态 if ((scenegraph.table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE)) { @@ -86,6 +142,22 @@ export function bindScrollBarListener(eventManager: EventManager) { }); } }); + scenegraph.component.frozenHScrollBar.addEventListener('pointerdown', (e: FederatedPointerEvent) => { + e.stopPropagation(); + if ((scenegraph.table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE)) { + scenegraph.table.fireListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE, { + event: e.nativeEvent + }); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointerdown', (e: FederatedPointerEvent) => { + e.stopPropagation(); + if ((scenegraph.table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE)) { + scenegraph.table.fireListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE, { + event: e.nativeEvent + }); + } + }); scenegraph.component.hScrollBar.addEventListener('scrollDown', (e: FederatedPointerEvent) => { scenegraph.table.eventManager.LastBodyPointerXY = { x: e.x, y: e.y }; scenegraph.table.eventManager.isDown = true; @@ -100,6 +172,34 @@ export function bindScrollBarListener(eventManager: EventManager) { }); } }); + scenegraph.component.frozenHScrollBar.addEventListener('scrollDown', (e: FederatedPointerEvent) => { + scenegraph.table.eventManager.LastBodyPointerXY = { x: e.x, y: e.y }; + scenegraph.table.eventManager.isDown = true; + if (stateManager.interactionState !== InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.scrolling); + } + scenegraph.table.stateManager.hideMenu(); + (scenegraph.table as ListTableAPI).editorManager?.completeEdit(); + if ((scenegraph.table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE)) { + scenegraph.table.fireListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE, { + event: e.nativeEvent + }); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('scrollDown', (e: FederatedPointerEvent) => { + scenegraph.table.eventManager.LastBodyPointerXY = { x: e.x, y: e.y }; + scenegraph.table.eventManager.isDown = true; + if (stateManager.interactionState !== InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.scrolling); + } + scenegraph.table.stateManager.hideMenu(); + (scenegraph.table as ListTableAPI).editorManager?.completeEdit(); + if ((scenegraph.table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE)) { + scenegraph.table.fireListeners(TABLE_EVENT_TYPE.MOUSEDOWN_TABLE, { + event: e.nativeEvent + }); + } + }); scenegraph.component.hScrollBar.addEventListener('pointerup', () => { stateManager.fastScrolling = false; scenegraph.table.eventManager.isDraging = false; @@ -116,8 +216,42 @@ export function bindScrollBarListener(eventManager: EventManager) { scenegraph.component.hScrollBar.addEventListener('scrollUp', (e: FederatedPointerEvent) => { scenegraph.table.eventManager.isDraging = false; }); + scenegraph.component.frozenHScrollBar.addEventListener('pointerup', () => { + stateManager.fastScrolling = false; + scenegraph.table.eventManager.isDraging = false; + if (stateManager.interactionState === InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.default); + } + }); + scenegraph.component.frozenHScrollBar.addEventListener('pointerupoutside', () => { + stateManager.fastScrolling = false; + if (stateManager.interactionState === InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.default); + } + }); + scenegraph.component.frozenHScrollBar.addEventListener('scrollUp', (e: FederatedPointerEvent) => { + scenegraph.table.eventManager.isDraging = false; + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointerup', () => { + stateManager.fastScrolling = false; + scenegraph.table.eventManager.isDraging = false; + if (stateManager.interactionState === InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.default); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('pointerupoutside', () => { + stateManager.fastScrolling = false; + if (stateManager.interactionState === InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.default); + } + }); + scenegraph.component.rightFrozenHScrollBar.addEventListener('scrollUp', (e: FederatedPointerEvent) => { + scenegraph.table.eventManager.isDraging = false; + }); const throttleVerticalWheel = throttle(stateManager.updateVerticalScrollBar, 20); const throttleHorizontalWheel = throttle(stateManager.updateHorizontalScrollBar, 20); + const throttleFrozenHorizontalWheel = throttle(stateManager.updateFrozenHorizontalScrollBar, 20); + const throttleRightFrozenHorizontalWheel = throttle(stateManager.updateRightFrozenHorizontalScrollBar, 20); // 监听滚动条组件scroll事件 scenegraph.component.vScrollBar.addEventListener('scrollDrag', (e: any) => { @@ -158,4 +292,34 @@ export function bindScrollBarListener(eventManager: EventManager) { // stateManager.table.scenegraph.proxy.isSkipProgress = false; // }, 10); }); + + scenegraph.component.frozenHScrollBar.addEventListener('scrollDrag', (e: any) => { + if (scenegraph.table.eventManager.isDown) { + scenegraph.table.eventManager.isDraging = true; + } + stateManager.fastScrolling = true; + if (stateManager.interactionState !== InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.scrolling); + } + scenegraph.table.stateManager.hideMenu(); + (scenegraph.table as ListTableAPI).editorManager?.completeEdit(); + table.scenegraph.deactivateChart(-1, -1, true); + const ratio = e.detail.value[0] / (1 - e.detail.value[1] + e.detail.value[0]); + throttleFrozenHorizontalWheel(ratio); + }); + + scenegraph.component.rightFrozenHScrollBar.addEventListener('scrollDrag', (e: any) => { + if (scenegraph.table.eventManager.isDown) { + scenegraph.table.eventManager.isDraging = true; + } + stateManager.fastScrolling = true; + if (stateManager.interactionState !== InteractionState.scrolling) { + stateManager.updateInteractionState(InteractionState.scrolling); + } + scenegraph.table.stateManager.hideMenu(); + (scenegraph.table as ListTableAPI).editorManager?.completeEdit(); + table.scenegraph.deactivateChart(-1, -1, true); + const ratio = e.detail.value[0] / (1 - e.detail.value[1] + e.detail.value[0]); + throttleRightFrozenHorizontalWheel(ratio); + }); } diff --git a/packages/vtable/src/event/listener/table-group.ts b/packages/vtable/src/event/listener/table-group.ts index affdca98f6..3c466dc351 100644 --- a/packages/vtable/src/event/listener/table-group.ts +++ b/packages/vtable/src/event/listener/table-group.ts @@ -9,7 +9,7 @@ import type { } from '../../ts-types'; import { IconFuncTypeEnum, InteractionState } from '../../ts-types'; import type { SceneEvent } from '../util'; -import { getCellEventArgsSet, regIndexReg } from '../util'; +import { getCellEventArgsSetWithTable, regIndexReg } from '../util'; import { TABLE_EVENT_TYPE } from '../../core/TABLE_EVENT_TYPE'; import type { Group } from '../../scenegraph/graphic/group'; import { isValid } from '@visactor/vutils'; @@ -27,6 +27,7 @@ import { clearPageSelection } from '../../tools/env'; export function bindTableGroupListener(eventManager: EventManager) { const table = eventManager.table; const stateManager = table.stateManager; + const getEventArgsSet = (e: FederatedPointerEvent) => getCellEventArgsSetWithTable(e, table); table.scenegraph.tableGroup.addEventListener('pointermove', (e: FederatedPointerEvent) => { const lastX = table.eventManager.LastPointerXY?.x ?? e.x; @@ -38,7 +39,7 @@ export function bindTableGroupListener(eventManager: EventManager) { clearTimeout(eventManager.touchSetTimeout); eventManager.touchSetTimeout = undefined; } - const eventArgsSet = getCellEventArgsSet(e); + const eventArgsSet = getEventArgsSet(e); // if (stateManager.interactionState === InteractionState.scrolling) { // return; // } @@ -178,10 +179,31 @@ export function bindTableGroupListener(eventManager: EventManager) { mergeCellInfo: eventArgsSet.eventArgs?.mergeInfo }); } + + if ( + (table.theme.scrollStyle.horizontalVisible && table.theme.scrollStyle.horizontalVisible === 'focus') || + (!table.theme.scrollStyle.horizontalVisible && table.theme.scrollStyle.visible === 'focus') + ) { + // focus 模式下:根据鼠标当前所在的区域,仅显示该区域对应的横向滚动条, + // 避免 body/左冻结/右冻结三段滚动条同时展示造成干扰。 + const relativeX = e.x - table.tableX; + const target = + table.options.scrollFrozenCols && + table.getFrozenColsOffset?.() > 0 && + relativeX >= 0 && + relativeX < table.getFrozenColsWidth() + ? 'frozen' + : table.options.scrollRightFrozenCols && + table.getRightFrozenColsOffset?.() > 0 && + relativeX > table.tableNoFrameWidth - table.getRightFrozenColsWidth() + ? 'rightFrozen' + : 'body'; + stateManager.showHorizontalScrollBar(false, target); + } }); table.scenegraph.tableGroup.addEventListener('pointerout', (e: FederatedPointerEvent) => { - const eventArgsSet = getCellEventArgsSet(e); + const eventArgsSet = getEventArgsSet(e); const cellGoup = eventArgsSet?.eventArgs?.target as unknown as Group; if (cellGoup?.role === 'table') { eventManager.dealTableHover(); @@ -189,7 +211,7 @@ export function bindTableGroupListener(eventManager: EventManager) { }); table.scenegraph.tableGroup.addEventListener('pointerover', (e: FederatedPointerEvent) => { - const eventArgsSet = getCellEventArgsSet(e); + const eventArgsSet = getEventArgsSet(e); const cellGoup = eventArgsSet?.eventArgs?.target as unknown as Group; // console.log('pointerover', cellGoup); @@ -266,7 +288,19 @@ export function bindTableGroupListener(eventManager: EventManager) { (table.theme.scrollStyle.horizontalVisible && table.theme.scrollStyle.horizontalVisible === 'focus') || (!table.theme.scrollStyle.horizontalVisible && table.theme.scrollStyle.visible === 'focus') ) { - stateManager.showHorizontalScrollBar(); + const relativeX = e.x - table.tableX; + const target = + table.options.scrollFrozenCols && + table.getFrozenColsOffset?.() > 0 && + relativeX >= 0 && + relativeX < table.getFrozenColsWidth() + ? 'frozen' + : table.options.scrollRightFrozenCols && + table.getRightFrozenColsOffset?.() > 0 && + relativeX > table.tableNoFrameWidth - table.getRightFrozenColsWidth() + ? 'rightFrozen' + : 'body'; + stateManager.showHorizontalScrollBar(false, target); } if ( (table.theme.scrollStyle.verticalVisible && table.theme.scrollStyle.verticalVisible === 'focus') || @@ -415,7 +449,7 @@ export function bindTableGroupListener(eventManager: EventManager) { // 只处理左键 return; } - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); eventManager.downIcon = undefined; if (stateManager.interactionState !== InteractionState.default) { return; @@ -564,7 +598,7 @@ export function bindTableGroupListener(eventManager: EventManager) { stateManager.updateInteractionState(InteractionState.grabing); } if ((table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEDOWN_CELL)) { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); if (eventArgsSet.eventArgs) { table.fireListeners(TABLE_EVENT_TYPE.MOUSEDOWN_CELL, { col: eventArgsSet.eventArgs.col, @@ -601,7 +635,7 @@ export function bindTableGroupListener(eventManager: EventManager) { if (table.stateManager.isFillHandle()) { table.stateManager.endFillSelect(); } - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); if ( table.eventManager.isDraging && eventArgsSet.eventArgs && @@ -627,7 +661,7 @@ export function bindTableGroupListener(eventManager: EventManager) { } if (!table.eventManager.isDraging) { // 从pointertap中挪过来的这段逻辑 - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); if ( !eventManager.isTouchMove && e.button === 0 && @@ -667,7 +701,7 @@ export function bindTableGroupListener(eventManager: EventManager) { // console.log('DRAG_SELECT_END'); if ((table as any).hasListeners(TABLE_EVENT_TYPE.MOUSEUP_CELL)) { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); if (eventArgsSet.eventArgs) { table.fireListeners(TABLE_EVENT_TYPE.MOUSEUP_CELL, { col: eventArgsSet.eventArgs.col, @@ -688,7 +722,7 @@ export function bindTableGroupListener(eventManager: EventManager) { }); table.scenegraph.tableGroup.addEventListener('rightdown', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); // 右键点击 if (eventArgsSet.eventArgs) { stateManager.triggerContextMenu( @@ -775,7 +809,7 @@ export function bindTableGroupListener(eventManager: EventManager) { if (table.stateManager.columnResize.resizing) { return; } - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); // 触发click_cell事件的逻辑挪到了pointerup中 // if ( // !eventManager.isTouchMove && @@ -828,7 +862,8 @@ export function bindTableGroupListener(eventManager: EventManager) { // 通过这个变量判断非drag鼠标拖拽状态,就不再增加其他变量isDrag了(touchSetTimeout如果拖拽过会变成undefined pointermove事件有置为undefined) if (e.pointerType === 'touch') { // 移动端事件特殊处理 - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); + // replaced by getEventArgsSet above if (eventManager.touchSetTimeout) { clearTimeout(eventManager.touchSetTimeout); const isHasSelected = !!stateManager.select.ranges?.length; @@ -841,7 +876,7 @@ export function bindTableGroupListener(eventManager: EventManager) { }); // stage 的pointerdown监听 table.scenegraph.stage.addEventListener('pointerdown', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getCellEventArgsSetWithTable(e, table); if ( !eventArgsSet.eventArgs?.target || (eventArgsSet.eventArgs?.target as any) !== stateManager.residentHoverIcon?.icon @@ -947,7 +982,7 @@ export function bindTableGroupListener(eventManager: EventManager) { } }); table.scenegraph.stage.addEventListener('pointermove', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getCellEventArgsSetWithTable(e, table); // 检查事件是否来自当前表格的有效区域 const isEventFromCurrentTable = e.target?.isDescendantsOf?.(table.scenegraph.tableGroup) ?? false; @@ -988,7 +1023,7 @@ export function bindTableGroupListener(eventManager: EventManager) { // }); table.scenegraph.tableGroup.addEventListener('checkbox_state_change', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); const { col, row } = eventArgsSet.eventArgs; const cellInfo = table.getCellInfo(col, row); @@ -1058,7 +1093,7 @@ export function bindTableGroupListener(eventManager: EventManager) { }); table.scenegraph.tableGroup.addEventListener('radio_checked', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); const { col, row, target } = eventArgsSet.eventArgs; const cellInfo = table.getCellInfo(col, row); const indexInCell: string | undefined = regIndexReg.exec(target.id as string)?.[1]; @@ -1143,7 +1178,7 @@ export function bindTableGroupListener(eventManager: EventManager) { }); table.scenegraph.tableGroup.addEventListener('switch_state_change', (e: FederatedPointerEvent) => { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getEventArgsSet(e); const { col, row, target } = eventArgsSet.eventArgs; const cellInfo = table.getCellInfo(col, row); @@ -1241,7 +1276,7 @@ export function endResizeRow(table: BaseTableAPI) { } function dblclickHandler(e: FederatedPointerEvent, table: BaseTableAPI) { - const eventArgsSet: SceneEvent = getCellEventArgsSet(e); + const eventArgsSet: SceneEvent = getCellEventArgsSetWithTable(e, table); let col = -1; let row = -1; if (eventArgsSet.eventArgs) { diff --git a/packages/vtable/src/event/scroll.ts b/packages/vtable/src/event/scroll.ts index 2f0bd27bff..6932f05dfe 100644 --- a/packages/vtable/src/event/scroll.ts +++ b/packages/vtable/src/event/scroll.ts @@ -22,14 +22,94 @@ export function handleWhell(event: FederatedWheelEvent, state: StateManager, isW state.updateInteractionState(InteractionState.scrolling); } } - const autoHide = state.table.options.theme?.scrollStyle?.visible === 'scrolling'; + const visible1 = state.table.theme.scrollStyle?.visible; + const horizontalVisible = state.table.theme.scrollStyle?.horizontalVisible ?? visible1; + const verticalVisible = state.table.theme.scrollStyle?.verticalVisible ?? visible1; + const horizontalAutoHide = horizontalVisible === 'scrolling'; + const verticalAutoHide = verticalVisible === 'scrolling'; + let usedFrozenHorizontal = false; + let usedRightFrozenHorizontal = false; + let blockedFrozenHorizontal = false; + let blockedRightFrozenHorizontal = false; if (optimizedDeltaX) { - state.setScrollLeft(state.scroll.horizontalBarPos + optimizedDeltaX, event); - state.showHorizontalScrollBar(autoHide); + // 横向滚动需要判断“滚动意图属于哪个区域”: + // - body:主滚动域(影响非冻结列) + // - frozen:左侧冻结区域内部滚动(scrollFrozenCols 开启且溢出时) + // - rightFrozen:右侧冻结区域内部滚动(scrollRightFrozenCols 开启且溢出时) + // + // 部分浏览器/输入设备的 wheel 事件不一定带坐标(event.x/y),因此这里会回退使用 LastBodyPointerXY。 + const pxEvent = (event as any).x; + const pyEvent = (event as any).y; + const pxFallback = (state.table as any).eventManager?.LastBodyPointerXY?.x; + const pyFallback = (state.table as any).eventManager?.LastBodyPointerXY?.y; + const px = typeof pxEvent === 'number' ? pxEvent : pxFallback; + const py = typeof pyEvent === 'number' ? pyEvent : pyFallback; + const usedFallback = typeof pxEvent !== 'number' || typeof pyEvent !== 'number'; + const relativeX = typeof px === 'number' ? px - state.table.tableX : NaN; + const relativeY = typeof py === 'number' ? py - state.table.tableY : NaN; + const isInTable = + isFinite(relativeX) && + isFinite(relativeY) && + relativeX >= 0 && + relativeY >= 0 && + relativeX <= state.table.tableNoFrameWidth && + relativeY <= state.table.tableNoFrameHeight; + const frozenColsScrollable = state.table.options.scrollFrozenCols && state.table.getFrozenColsOffset() > 0; + const rightFrozenColsScrollable = + state.table.options.scrollRightFrozenCols && state.table.getRightFrozenColsOffset() > 0; + const isInFrozenViewport = isInTable && relativeX > 0 && relativeX < state.table.getFrozenColsWidth(); + const isInRightFrozenViewport = + isInTable && relativeX > state.table.tableNoFrameWidth - state.table.getRightFrozenColsWidth(); + + if (frozenColsScrollable && isInFrozenViewport) { + const maxFrozenScrollLeft = state.table.getFrozenColsOffset(); + const nextFrozenScrollLeft = state.scroll.frozenHorizontalBarPos + optimizedDeltaX; + const canScrollFrozen = + (optimizedDeltaX < 0 && state.scroll.frozenHorizontalBarPos > 0) || + (optimizedDeltaX > 0 && state.scroll.frozenHorizontalBarPos < maxFrozenScrollLeft); + if (canScrollFrozen) { + state.setFrozenColsScrollLeft(nextFrozenScrollLeft); + usedFrozenHorizontal = true; + state.showHorizontalScrollBar(horizontalAutoHide, 'frozen'); + } else { + if (state.table.options.scrollFrozenColsPassThroughToBody) { + state.setScrollLeft(state.scroll.horizontalBarPos + optimizedDeltaX, event); + state.showHorizontalScrollBar(horizontalAutoHide, 'body'); + } else { + blockedFrozenHorizontal = true; + state.showHorizontalScrollBar(horizontalAutoHide, 'frozen'); + } + } + } else if (rightFrozenColsScrollable && isInRightFrozenViewport) { + const maxRightFrozenScrollLeft = state.table.getRightFrozenColsOffset(); + // 右冻结区域的内容从右向左展开。为了让触摸板的“向左滑”表现为“展开更多右冻结列”, + // 这里将 deltaX 反向映射到 rightFrozen 的 scrollLeft。 + const rightFrozenDelta = -optimizedDeltaX; + const nextRightFrozenScrollLeft = state.scroll.rightFrozenHorizontalBarPos + rightFrozenDelta; + const canScrollRightFrozen = + (rightFrozenDelta < 0 && state.scroll.rightFrozenHorizontalBarPos > 0) || + (rightFrozenDelta > 0 && state.scroll.rightFrozenHorizontalBarPos < maxRightFrozenScrollLeft); + if (canScrollRightFrozen) { + state.setRightFrozenColsScrollLeft(nextRightFrozenScrollLeft); + usedRightFrozenHorizontal = true; + state.showHorizontalScrollBar(horizontalAutoHide, 'rightFrozen'); + } else { + if (state.table.options.scrollFrozenColsPassThroughToBody) { + state.setScrollLeft(state.scroll.horizontalBarPos + optimizedDeltaX, event); + state.showHorizontalScrollBar(horizontalAutoHide, 'body'); + } else { + blockedRightFrozenHorizontal = true; + state.showHorizontalScrollBar(horizontalAutoHide, 'rightFrozen'); + } + } + } else { + state.setScrollLeft(state.scroll.horizontalBarPos + optimizedDeltaX, event); + state.showHorizontalScrollBar(horizontalAutoHide, 'body'); + } } if (optimizedDeltaY) { state.setScrollTop(state.scroll.verticalBarPos + optimizedDeltaY, event); - state.showVerticalScrollBar(autoHide); + state.showVerticalScrollBar(verticalAutoHide); } isWheelEvent && state.resetInteractionState(state.interactionStateBeforeScroll); if ( @@ -37,7 +117,15 @@ export function handleWhell(event: FederatedWheelEvent, state: StateManager, isW ((state.table.internalProps.overscrollBehavior === 'none' && ((deltaY && isVerticalExistScrollBar(state)) || (deltaX && isHorizontalExistScrollBar(state)))) || (Math.abs(deltaY) >= Math.abs(deltaX) && deltaY !== 0 && isVerticalScrollable(deltaY, state)) || - (Math.abs(deltaY) <= Math.abs(deltaX) && deltaX !== 0 && isHorizontalScrollable(deltaX, state))) + (Math.abs(deltaY) <= Math.abs(deltaX) && + deltaX !== 0 && + (blockedFrozenHorizontal || blockedRightFrozenHorizontal + ? false + : usedFrozenHorizontal + ? state.table.getFrozenColsOffset() > 0 + : usedRightFrozenHorizontal + ? state.table.getRightFrozenColsOffset() > 0 + : isHorizontalScrollable(deltaX, state)))) ) { event.nativeEvent.preventDefault(); } @@ -74,7 +162,9 @@ export function isVerticalScrollable(deltaY: number, state: StateManager) { } export function isHorizontalScrollable(deltaX: number, state: StateManager) { - const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width; + const frozenOffset = state.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = state.table.getRightFrozenColsOffset?.() ?? 0; + const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width - frozenOffset - rightFrozenOffset; if (totalWidth === 0) { return false; } @@ -90,7 +180,9 @@ export function isVerticalExistScrollBar(state: StateManager) { } export function isHorizontalExistScrollBar(state: StateManager) { - const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width; + const frozenOffset = state.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = state.table.getRightFrozenColsOffset?.() ?? 0; + const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width - frozenOffset - rightFrozenOffset; if (totalWidth <= 0) { return false; } @@ -111,7 +203,9 @@ function isScrollToBottom(deltaY: number, state: StateManager) { } function isScrollToLeft(deltaX: number, state: StateManager) { - const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width; + const frozenOffset = state.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = state.table.getRightFrozenColsOffset?.() ?? 0; + const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width - frozenOffset - rightFrozenOffset; return totalWidth !== 0 && deltaX <= 0 && state.scroll.horizontalBarPos < 1; } @@ -119,7 +213,9 @@ function isScrollToRight(deltaX: number, state: StateManager) { // 这里加入tolerance,避免出现无用滚动 const sizeTolerance = state.table.options.customConfig?._disableColumnAndRowSizeRound ? 1 : 0; - const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width; + const frozenOffset = state.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = state.table.getRightFrozenColsOffset?.() ?? 0; + const totalWidth = state.table.getAllColsWidth() - state.table.scenegraph.width - frozenOffset - rightFrozenOffset; return totalWidth !== 0 && deltaX >= 0 && Math.abs(state.scroll.horizontalBarPos - totalWidth) < 1 + sizeTolerance; } diff --git a/packages/vtable/src/event/util.ts b/packages/vtable/src/event/util.ts index b0ec78d6b7..0469ac0a13 100644 --- a/packages/vtable/src/event/util.ts +++ b/packages/vtable/src/event/util.ts @@ -2,6 +2,7 @@ import type { FederatedPointerEvent, IEventTarget } from '@src/vrender'; import type { Group } from '../scenegraph/graphic/group'; import type { MergeCellInfo } from '../ts-types'; import { isValid } from '@visactor/vutils'; +import type { BaseTableAPI } from '../ts-types/base-table'; export interface SceneEvent { abstractPos: { @@ -46,6 +47,46 @@ export function getCellEventArgsSet(e: FederatedPointerEvent): SceneEvent { return tableEvent; } +export function getCellEventArgsSetWithTable(e: FederatedPointerEvent, table: BaseTableAPI): SceneEvent { + const eventArgsSet = getCellEventArgsSet(e); + if (!eventArgsSet.eventArgs) { + return eventArgsSet; + } + if (!table.options.scrollFrozenCols || table.getFrozenColsOffset?.() === 0) { + return eventArgsSet; + } + + const xInTable = e.x - table.tableX; + const yInTable = e.y - table.tableY; + if (xInTable < 0 || yInTable < 0 || xInTable > table.tableNoFrameWidth || yInTable > table.tableNoFrameHeight) { + return eventArgsSet; + } + + const frozenViewportWidth = table.getFrozenColsWidth(); + const pickedCol = eventArgsSet.eventArgs.col; + const pickedRow = eventArgsSet.eventArgs.row; + const isPickedLeftFrozen = + pickedCol >= 0 && pickedCol < table.frozenColCount && !table.isRightFrozenColumn(pickedCol, pickedRow); + + if (isPickedLeftFrozen && xInTable >= frozenViewportWidth) { + const cell = table.getCellAtRelativePosition(e.x, e.y); + if (cell.col === -1 || cell.row === -1) { + return eventArgsSet; + } + const targetCell = table.scenegraph.getCell(cell.col, cell.row); + eventArgsSet.eventArgs = { + col: cell.col, + row: cell.row, + event: e, + targetCell, + mergeInfo: getMergeCellInfo(targetCell), + target: targetCell as unknown as IEventTarget + }; + } + + return eventArgsSet; +} + export function getTargetCell(target: any) { while (target && target.parent) { if (target.role === 'cell') { diff --git a/packages/vtable/src/scenegraph/component/table-component.ts b/packages/vtable/src/scenegraph/component/table-component.ts index b58f532e8d..9a9040b9ca 100644 --- a/packages/vtable/src/scenegraph/component/table-component.ts +++ b/packages/vtable/src/scenegraph/component/table-component.ts @@ -16,6 +16,12 @@ import { isValid } from '@visactor/vutils'; */ export class TableComponent { table: BaseTableAPI; + // 横向滚动条在多滚动域场景下需要“只显示当前目标区域”的那一段: + // - body:主滚动域 + // - frozen:左冻结内部滚动域 + // - rightFrozen:右冻结内部滚动域 + // - all:全部显示(visible: 'always' 或兼容场景) + _horizontalScrollBarTarget?: 'body' | 'frozen' | 'rightFrozen' | 'all'; border: IRect; // 表格外边框 // selectBorder: IRect; // 表格选择区域边框 @@ -26,8 +32,10 @@ export class TableComponent { rowResizeBgLine: ILine; // 表格列宽调整基准线背景 rowResizeLabel: IGroup; // 表格列宽调整标记 menu: MenuHandler; // 表格菜单 - vScrollBar: ScrollBar; // 表格横向滚动条 - hScrollBar: ScrollBar; // 表格纵向滚动条 + vScrollBar: ScrollBar; // 表格纵向滚动条 + hScrollBar: ScrollBar; // 表格横向滚动条(body 主滚动域) + frozenHScrollBar: ScrollBar; // 左冻结区横向滚动条 + rightFrozenHScrollBar: ScrollBar; // 右冻结区横向滚动条 frozenShadowLine: IRect; // 表格冻结列右侧阴影块 rightFrozenShadowLine: IRect; // 表格右侧冻结列左侧阴影块 drillIcon: DrillIcon; // drill icon @@ -254,9 +262,13 @@ export class TableComponent { const hoverOn = this.table.theme.scrollStyle.hoverOn; if (hoverOn && !this.table.theme.scrollStyle.barToSide) { componentGroup.addChild(this.hScrollBar); + componentGroup.addChild(this.frozenHScrollBar); + componentGroup.addChild(this.rightFrozenHScrollBar); componentGroup.addChild(this.vScrollBar); } else { componentGroup.stage.defaultLayer.addChild(this.hScrollBar); + componentGroup.stage.defaultLayer.addChild(this.frozenHScrollBar); + componentGroup.stage.defaultLayer.addChild(this.rightFrozenHScrollBar); componentGroup.stage.defaultLayer.addChild(this.vScrollBar); // // add scroll bar before border, avoid scroll hide by border globalCompositeOperation @@ -314,6 +326,42 @@ export class TableComponent { (this.hScrollBar as any).render(); this.hScrollBar.hideAll(); + // 左冻结/右冻结滚动条默认不展示,仅在对应区域开启内部滚动(scrollFrozenCols/scrollRightFrozenCols) + // 且该区域存在溢出(offset > 0)时显示。 + this.frozenHScrollBar = new ScrollBar({ + direction: 'horizontal', + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: this.table.tableNoFrameWidth, + height: width, + padding: horizontalPadding, + railStyle: { + fill: scrollRailColor + }, + sliderStyle, + range: [0, 0.1], + visible: false + }); + (this.frozenHScrollBar as any).render(); + this.frozenHScrollBar.hideAll(); + + this.rightFrozenHScrollBar = new ScrollBar({ + direction: 'horizontal', + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: this.table.tableNoFrameWidth, + height: width, + padding: horizontalPadding, + railStyle: { + fill: scrollRailColor + }, + sliderStyle, + range: [0, 0.1], + visible: false + }); + (this.rightFrozenHScrollBar as any).render(); + this.rightFrozenHScrollBar.hideAll(); + this.vScrollBar = new ScrollBar({ direction: 'vertical', x: -this.table.tableNoFrameWidth * 2, @@ -339,6 +387,8 @@ export class TableComponent { updateScrollBar() { const oldHorizontalBarPos = this.table.stateManager.scroll.horizontalBarPos; const oldVerticalBarPos = this.table.stateManager.scroll.verticalBarPos; + const oldFrozenHorizontalBarPos = this.table.stateManager.scroll.frozenHorizontalBarPos; + const oldRightFrozenHorizontalBarPos = this.table.stateManager.scroll.rightFrozenHorizontalBarPos; const theme = this.table.theme; const width = theme.scrollStyle?.width as number; @@ -355,8 +405,10 @@ export class TableComponent { const totalWidth = this.table.getAllColsWidth(); const frozenRowsHeight = this.table.getFrozenRowsHeight(); const frozenColsWidth = this.table.getFrozenColsWidth(); + const frozenColsContentWidth = this.table.getFrozenColsContentWidth?.() ?? frozenColsWidth; const bottomFrozenRowsHeight = this.table.getBottomFrozenRowsHeight(); const rightFrozenColsWidth = this.table.getRightFrozenColsWidth(); + const rightFrozenColsContentWidth = this.table.getRightFrozenColsContentWidth?.() ?? rightFrozenColsWidth; const hoverOn = this.table.theme.scrollStyle.hoverOn; // _disableColumnAndRowSizeRound环境中,可能出现 // getAllColsWidth/getAllRowsHeight(A) + getAllColsWidth/getAllRowsHeight(B) < getAllColsWidth/getAllRowsHeight(A+B) @@ -366,7 +418,12 @@ export class TableComponent { if (totalWidth > tableWidth + sizeTolerance) { const y = Math.min(tableHeight, totalHeight); - const rangeEnd = Math.max(0.05, (tableWidth - frozenColsWidth) / (totalWidth - frozenColsWidth)); + // 多滚动域下,body 的可视区域需要扣除左右冻结占用的视口宽度; + // body 的可滚动内容宽度也需要扣除左右冻结列的内容宽度。 + // 这样主滚动条的滑块长度能准确反映“body 可视宽 / body 内容宽”的比例。 + const bodyViewportWidth = tableWidth - frozenColsWidth - rightFrozenColsWidth; + const bodyContentWidth = totalWidth - frozenColsContentWidth - rightFrozenColsContentWidth; + const rangeEnd = bodyContentWidth > 0 ? Math.max(0.05, bodyViewportWidth / bodyContentWidth) : 1; let attrY = 0; if (this.table.theme.scrollStyle.barToSide) { @@ -408,6 +465,65 @@ export class TableComponent { if (horizontalVisible === 'always') { this.hScrollBar.showAll(); } + + const frozenScrollable = this.table.options.scrollFrozenCols && this.table.getFrozenColsOffset() > 0; + if (!ignoreFrozenCols && frozenScrollable) { + // 左冻结滚动条的滑块长度 = 冻结视口宽 / 冻结内容宽 + const frozenRangeEnd = Math.max(0.05, frozenColsWidth / frozenColsContentWidth); + const x = !hoverOn ? this.table.scenegraph.tableGroup.attribute.x : 0; + this.frozenHScrollBar.setAttributes({ + x, + y: attrY, + width: frozenColsWidth, + range: [0, frozenRangeEnd], + visible: horizontalVisible === 'always' + }); + const bounds = this.frozenHScrollBar.AABBBounds && this.frozenHScrollBar.globalAABBBounds; + (this.frozenHScrollBar as any)._viewPosition = { + x: bounds.x1, + y: bounds.y1 + }; + if (horizontalVisible === 'always') { + this.frozenHScrollBar.showAll(); + } + } else { + this.frozenHScrollBar.setAttributes({ + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: 0, + visible: false + }); + } + + const rightFrozenScrollable = + this.table.options.scrollRightFrozenCols && this.table.getRightFrozenColsOffset() > 0; + if (!ignoreFrozenCols && rightFrozenScrollable) { + // 右冻结滚动条的滑块长度 = 右冻结视口宽 / 右冻结内容宽 + const rightFrozenRangeEnd = Math.max(0.05, rightFrozenColsWidth / rightFrozenColsContentWidth); + const x = tableWidth - rightFrozenColsWidth + (!hoverOn ? this.table.scenegraph.tableGroup.attribute.x : 0); + this.rightFrozenHScrollBar.setAttributes({ + x, + y: attrY, + width: rightFrozenColsWidth, + range: [0, rightFrozenRangeEnd], + visible: horizontalVisible === 'always' + }); + const bounds = this.rightFrozenHScrollBar.AABBBounds && this.rightFrozenHScrollBar.globalAABBBounds; + (this.rightFrozenHScrollBar as any)._viewPosition = { + x: bounds.x1, + y: bounds.y1 + }; + if (horizontalVisible === 'always') { + this.rightFrozenHScrollBar.showAll(); + } + } else { + this.rightFrozenHScrollBar.setAttributes({ + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: 0, + visible: false + }); + } } else { this.hScrollBar.setAttributes({ x: -this.table.tableNoFrameWidth * 2, @@ -415,6 +531,18 @@ export class TableComponent { width: 0, visible: false }); + this.frozenHScrollBar.setAttributes({ + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: 0, + visible: false + }); + this.rightFrozenHScrollBar.setAttributes({ + x: -this.table.tableNoFrameWidth * 2, + y: -this.table.tableNoFrameHeight * 2, + width: 0, + visible: false + }); } if (totalHeight > tableHeight + sizeTolerance) { @@ -458,6 +586,8 @@ export class TableComponent { } this.table.stateManager.setScrollLeft(oldHorizontalBarPos); + this.table.stateManager.setFrozenColsScrollLeft(oldFrozenHorizontalBarPos, false); + this.table.stateManager.setRightFrozenColsScrollLeft(oldRightFrozenHorizontalBarPos, false); this.table.stateManager.setScrollTop(oldVerticalBarPos); } @@ -667,7 +797,10 @@ export class TableComponent { * @return {*} */ setFrozenColumnShadow(col: number, isRightFrozen?: boolean) { - const colX = getColX(col, this.table, isRightFrozen); + const colX = + !isRightFrozen && col === this.table.frozenColCount - 1 && (this.table.getFrozenColsOffset?.() ?? 0) > 0 + ? this.table.getFrozenColsWidth() + : getColX(col, this.table, isRightFrozen); if (col < 0 || this.table.theme.frozenColumnLine?.shadow?.visible !== 'always') { this.frozenShadowLine.setAttributes({ visible: false, @@ -689,7 +822,14 @@ export class TableComponent { * @return {*} */ setRightFrozenColumnShadow(col: number) { - const colX = getColX(col, this.table, true); + // 右冻结阴影线应当绘制在“右冻结视口的左边界”。 + // 在 scrollRightFrozenCols 开启且存在溢出时,右冻结内容会在视口内滚动, + // 因此不能用 getColX(受内容滚动影响)来定位分界线,而是固定在 viewport 边界。 + const shouldFixViewport = + this.table.options.scrollRightFrozenCols && (this.table.getRightFrozenColsOffset?.() ?? 0) > 0; + const colX = shouldFixViewport + ? this.table.tableNoFrameWidth - this.table.getRightFrozenColsWidth() + : getColX(col, this.table, true); if (col >= this.table.colCount || this.table.theme.frozenColumnLine?.shadow?.visible !== 'always') { this.rightFrozenShadowLine.setAttributes({ visible: false, @@ -752,16 +892,40 @@ export class TableComponent { } this.hScrollBar.setAttribute('visible', false); this.hScrollBar.hideAll(); + this.frozenHScrollBar.setAttribute('visible', false); + this.frozenHScrollBar.hideAll(); + this.rightFrozenHScrollBar.setAttribute('visible', false); + this.rightFrozenHScrollBar.hideAll(); + // 清空当前显示目标,确保下一次 show 时能正确切换显示区域 + this._horizontalScrollBarTarget = undefined; this.table.scenegraph.updateNextFrame(); } - showHorizontalScrollBar() { + showHorizontalScrollBar(target: 'body' | 'frozen' | 'rightFrozen' | 'all' = 'all') { const visible1 = this.table.theme.scrollStyle.visible; const horizontalVisible = this.table.theme.scrollStyle.horizontalVisible ?? visible1; if (horizontalVisible !== 'focus' && horizontalVisible !== 'scrolling') { return; } - this.hScrollBar.setAttribute('visible', true); - this.hScrollBar.showAll(); + if (this._horizontalScrollBarTarget === target) { + return; + } + this._horizontalScrollBarTarget = target; + + const showBody = target === 'all' || target === 'body'; + const showFrozen = target === 'all' || target === 'frozen'; + const showRightFrozen = target === 'all' || target === 'rightFrozen'; + + const bodyVisible = showBody && this.hScrollBar.attribute.width > 0; + this.hScrollBar.setAttribute('visible', bodyVisible); + bodyVisible ? this.hScrollBar.showAll() : this.hScrollBar.hideAll(); + + const frozenVisible = showFrozen && this.frozenHScrollBar.attribute.width > 0; + this.frozenHScrollBar.setAttribute('visible', frozenVisible); + frozenVisible ? this.frozenHScrollBar.showAll() : this.frozenHScrollBar.hideAll(); + + const rightFrozenVisible = showRightFrozen && this.rightFrozenHScrollBar.attribute.width > 0; + this.rightFrozenHScrollBar.setAttribute('visible', rightFrozenVisible); + rightFrozenVisible ? this.rightFrozenHScrollBar.showAll() : this.rightFrozenHScrollBar.hideAll(); this.table.scenegraph.updateNextFrame(); } updateVerticalScrollBarPos(topRatio: number) { @@ -786,6 +950,28 @@ export class TableComponent { y: bounds.y1 }; } + updateFrozenHorizontalScrollBarPos(leftRatio: number) { + const range = this.frozenHScrollBar.attribute.range; + const size = range[1] - range[0]; + const range0 = leftRatio * (1 - size); + this.frozenHScrollBar.setAttribute('range', [range0, range0 + size]); + const bounds = this.frozenHScrollBar.AABBBounds && this.frozenHScrollBar.globalAABBBounds; + (this.frozenHScrollBar as any)._viewPosition = { + x: bounds.x1, + y: bounds.y1 + }; + } + updateRightFrozenHorizontalScrollBarPos(leftRatio: number) { + const range = this.rightFrozenHScrollBar.attribute.range; + const size = range[1] - range[0]; + const range0 = leftRatio * (1 - size); + this.rightFrozenHScrollBar.setAttribute('range', [range0, range0 + size]); + const bounds = this.rightFrozenHScrollBar.AABBBounds && this.rightFrozenHScrollBar.globalAABBBounds; + (this.rightFrozenHScrollBar as any)._viewPosition = { + x: bounds.x1, + y: bounds.y1 + }; + } updateStyle() { const theme = this.table.theme; @@ -826,6 +1012,22 @@ export class TableComponent { }, sliderStyle }); + this.frozenHScrollBar.setAttributes({ + height: width, + padding: horizontalPadding, + railStyle: { + fill: scrollRailColor + }, + sliderStyle + }); + this.rightFrozenHScrollBar.setAttributes({ + height: width, + padding: horizontalPadding, + railStyle: { + fill: scrollRailColor + }, + sliderStyle + }); // columnResizeLine & columnResizeBgLine const columnResizeColor = theme.columnResize?.lineColor; diff --git a/packages/vtable/src/scenegraph/component/util.ts b/packages/vtable/src/scenegraph/component/util.ts index 3976146486..29b098a0db 100644 --- a/packages/vtable/src/scenegraph/component/util.ts +++ b/packages/vtable/src/scenegraph/component/util.ts @@ -2,11 +2,21 @@ import type { BaseTableAPI } from '../../ts-types/base-table'; export function getColX(col: number, table: BaseTableAPI, isRightFrozen?: boolean) { if (isRightFrozen) { - return Math.min(table.tableNoFrameWidth, table.getAllColsWidth()) - table.getColsWidth(col, table.colCount - 1); + // 右冻结列的 x 位置以“表格最右侧”为基准向左累加。 + // 当开启右冻结区域内部滚动时,需要叠加右冻结的 scrollLeft,使得列在右冻结视口内可左右移动。 + return ( + Math.min(table.tableNoFrameWidth, table.getAllColsWidth()) - + table.getColsWidth(col, table.colCount - 1) + + (table.getRightFrozenColsScrollLeft?.() ?? 0) + ); } + const frozenOffset = table.getFrozenColsOffset?.() ?? 0; + const frozenScrollLeft = table.getFrozenColsScrollLeft?.() ?? 0; let colX = table.getColsWidth(0, col); if (col >= table.frozenColCount) { - colX -= table.scrollLeft; + colX -= table.scrollLeft + frozenOffset; + } else { + colX -= frozenScrollLeft; } return colX; } diff --git a/packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts b/packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts index e313877a25..5ff051bf45 100644 --- a/packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts +++ b/packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts @@ -963,59 +963,91 @@ export class ClipBodyGroupBeforeRenderContribution implements IGroupRenderContri return; } + const clipInflate = getSelectOverlayClipInflate(group as Group, table); + if ((group as Group).role === 'body') { - const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth(); - const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight(); - const width = group.parent.attribute.width - table.getFrozenColsWidth() - table.getRightFrozenColsWidth(); - const height = group.parent.attribute.height - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth() - clipInflate.left; + const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight() - clipInflate.top; + const width = + group.parent.attribute.width - + table.getFrozenColsWidth() - + table.getRightFrozenColsWidth() + + clipInflate.left + + clipInflate.right; + const height = + group.parent.attribute.height - + table.getFrozenRowsHeight() - + table.getBottomFrozenRowsHeight() + + clipInflate.top + + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'row-header') { - const x = 0; - const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight(); - const width = table.getFrozenColsWidth(); - const height = group.parent.attribute.height - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight() - clipInflate.top; + const width = table.getFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = + group.parent.attribute.height - + table.getFrozenRowsHeight() - + table.getBottomFrozenRowsHeight() + + clipInflate.top + + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'col-header') { - const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth(); - const y = 0; - const width = group.parent.attribute.width - table.getFrozenColsWidth() - table.getRightFrozenColsWidth(); - const height = table.getFrozenRowsHeight(); + const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth() - clipInflate.left; + const y = 0 - clipInflate.top; + const width = + group.parent.attribute.width - + table.getFrozenColsWidth() - + table.getRightFrozenColsWidth() + + clipInflate.left + + clipInflate.right; + const height = table.getFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'right-frozen') { - const x = 0; - const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight(); - const width = table.getRightFrozenColsWidth(); - const height = group.parent.attribute.height - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = -(group.attribute.y ?? 0) + table.getFrozenRowsHeight() - clipInflate.top; + const width = table.getRightFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = + group.parent.attribute.height - + table.getFrozenRowsHeight() - + table.getBottomFrozenRowsHeight() + + clipInflate.top + + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'bottom-frozen') { - const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth(); - const y = 0; - const width = group.parent.attribute.width - table.getFrozenColsWidth() - table.getRightFrozenColsWidth(); - const height = table.getBottomFrozenRowsHeight(); + const x = -(group.attribute.x ?? 0) + table.getFrozenColsWidth() - clipInflate.left; + const y = 0 - clipInflate.top; + const width = + group.parent.attribute.width - + table.getFrozenColsWidth() - + table.getRightFrozenColsWidth() + + clipInflate.left + + clipInflate.right; + const height = table.getBottomFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'corner-header') { - const x = 0; - const y = 0; - const width = table.getFrozenColsWidth(); - const height = table.getFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = 0 - clipInflate.top; + const width = table.getFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = table.getFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'corner-right-top-header') { - const x = 0; - const y = 0; - const width = table.getRightFrozenColsWidth(); - const height = table.getFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = 0 - clipInflate.top; + const width = table.getRightFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = table.getFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'corner-right-bottom-header') { - const x = 0; - const y = 0; - const width = table.getRightFrozenColsWidth(); - const height = table.getBottomFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = 0 - clipInflate.top; + const width = table.getRightFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = table.getBottomFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } else if ((group as Group).role === 'corner-left-bottom-header') { - const x = 0; - const y = 0; - const width = table.getFrozenColsWidth(); - const height = table.getBottomFrozenRowsHeight(); + const x = 0 - clipInflate.left; + const y = 0 - clipInflate.top; + const width = table.getFrozenColsWidth() + clipInflate.left + clipInflate.right; + const height = table.getBottomFrozenRowsHeight() + clipInflate.top + clipInflate.bottom; drawClipRect(context, x, y, width, height); } } @@ -1023,6 +1055,39 @@ export class ClipBodyGroupBeforeRenderContribution implements IGroupRenderContri const precision = Math.pow(2, 24); +function getSelectOverlayClipInflate(group: Group, table: BaseTableAPI) { + const isSelectOverlay = (group as any).name === 'select-overlay'; + if (!isSelectOverlay) { + return { left: 0, top: 0, right: 0, bottom: 0 }; + } + + // 选区 overlay 组会被各个区域的 clipRect 裁剪。 + // 当选区贴边(表格边缘/冻结分区边缘)时,边框外描边与 fill handle(右下角小方块) + // 可能被 clipRect 截断,因此对 overlay 的 clipRect 做“外扩”处理。 + // + // 其中: + // - baseInflate:覆盖 selection border 的线宽(避免只显示一半) + // - handleInflate:当开启 fillHandle 且只有一个选区时,为 6x6 的 handle 预留溢出空间(半径 3px) + const lineWidth = table.theme.selectionStyle?.cellBorderLineWidth; + const maxLineWidth = Array.isArray(lineWidth) + ? Math.max(...lineWidth.filter(v => typeof v === 'number')) + : typeof lineWidth === 'number' + ? lineWidth + : 0; + + const baseInflate = Math.max(1, Math.ceil(maxLineWidth / 2) + 1); + const shouldInflateForFillHandle = + !!table.options.excelOptions?.fillHandle && table.stateManager.select.ranges?.length === 1; + const handleInflate = shouldInflateForFillHandle ? 3 : 0; + + return { + left: baseInflate, + top: baseInflate, + right: Math.max(baseInflate, handleInflate), + bottom: Math.max(baseInflate, handleInflate) + }; +} + function drawClipRect(context: IContext2d, x: number, y: number, width: number, height: number) { context.beginPath(); diff --git a/packages/vtable/src/scenegraph/group-creater/init-scenegraph.ts b/packages/vtable/src/scenegraph/group-creater/init-scenegraph.ts index 68ca0691f2..b110d86e27 100644 --- a/packages/vtable/src/scenegraph/group-creater/init-scenegraph.ts +++ b/packages/vtable/src/scenegraph/group-creater/init-scenegraph.ts @@ -57,6 +57,51 @@ export function initSceneGraph(scene: Scenegraph) { leftBottomCornerGroup.role = 'corner-left-bottom-header'; scene.leftBottomCornerGroup = leftBottomCornerGroup; + const bodySelectGroup = createContainerGroup(width, 0, true); + bodySelectGroup.role = 'body'; + bodySelectGroup.name = 'select-overlay'; + scene.bodySelectGroup = bodySelectGroup; + + const rowHeaderSelectGroup = createContainerGroup(0, 0, true); + rowHeaderSelectGroup.role = 'row-header'; + rowHeaderSelectGroup.name = 'select-overlay'; + scene.rowHeaderSelectGroup = rowHeaderSelectGroup; + + const bottomFrozenSelectGroup = createContainerGroup(0, 0, true); + bottomFrozenSelectGroup.role = 'bottom-frozen'; + bottomFrozenSelectGroup.name = 'select-overlay'; + scene.bottomFrozenSelectGroup = bottomFrozenSelectGroup; + + const colHeaderSelectGroup = createContainerGroup(0, 0, true); + colHeaderSelectGroup.role = 'col-header'; + colHeaderSelectGroup.name = 'select-overlay'; + scene.colHeaderSelectGroup = colHeaderSelectGroup; + + const rightFrozenSelectGroup = createContainerGroup(0, 0, true); + rightFrozenSelectGroup.role = 'right-frozen'; + rightFrozenSelectGroup.name = 'select-overlay'; + scene.rightFrozenSelectGroup = rightFrozenSelectGroup; + + const rightTopCornerSelectGroup = createContainerGroup(0, 0, true); + rightTopCornerSelectGroup.role = 'corner-right-top-header'; + rightTopCornerSelectGroup.name = 'select-overlay'; + scene.rightTopCornerSelectGroup = rightTopCornerSelectGroup; + + const rightBottomCornerSelectGroup = createContainerGroup(0, 0, true); + rightBottomCornerSelectGroup.role = 'corner-right-bottom-header'; + rightBottomCornerSelectGroup.name = 'select-overlay'; + scene.rightBottomCornerSelectGroup = rightBottomCornerSelectGroup; + + const leftBottomCornerSelectGroup = createContainerGroup(0, 0, true); + leftBottomCornerSelectGroup.role = 'corner-left-bottom-header'; + leftBottomCornerSelectGroup.name = 'select-overlay'; + scene.leftBottomCornerSelectGroup = leftBottomCornerSelectGroup; + + const cornerHeaderSelectGroup = createContainerGroup(0, 0, true); + cornerHeaderSelectGroup.role = 'corner-header'; + cornerHeaderSelectGroup.name = 'select-overlay'; + scene.cornerHeaderSelectGroup = cornerHeaderSelectGroup; + scene.tableGroup.addChild(bodyGroup); //注意这块添加的顺序 会影响select框选效果 有可能引起框选框覆盖其他部分group的问题 具体问题出在update-select-border文件中的updateComponent方法 scene.tableGroup.addChild(rowHeaderGroup); @@ -69,6 +114,16 @@ export function initSceneGraph(scene: Scenegraph) { scene.tableGroup.addChild(rightTopCornerGroup); scene.tableGroup.addChild(leftBottomCornerGroup); scene.tableGroup.addChild(cornerHeaderGroup); + + scene.tableGroup.addChild(bodySelectGroup); + scene.tableGroup.addChild(rowHeaderSelectGroup); + scene.tableGroup.addChild(bottomFrozenSelectGroup); + scene.tableGroup.addChild(colHeaderSelectGroup); + scene.tableGroup.addChild(rightFrozenSelectGroup); + scene.tableGroup.addChild(rightBottomCornerSelectGroup); + scene.tableGroup.addChild(rightTopCornerSelectGroup); + scene.tableGroup.addChild(leftBottomCornerSelectGroup); + scene.tableGroup.addChild(cornerHeaderSelectGroup); scene.tableGroup.addChild(componentGroup); } diff --git a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts index 702e5c9d0d..d044569fd3 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/proxy.ts @@ -526,7 +526,9 @@ export class SceneProxy { this.table.getColsWidth(this.bodyLeftCol, this.bodyLeftCol + (this.colEnd - this.colStart + 1)) / 2; const xLimitRight = this.table.getAllColsWidth() - xLimitLeft; - const screenLeft = this.table.getTargetColAt(x + this.table.scenegraph.rowHeaderGroup.attribute.width); + const screenLeft = this.table.getTargetColAt( + x + this.table.scenegraph.rowHeaderGroup.attribute.width + (this.table.getFrozenColsOffset?.() ?? 0) + ); if (screenLeft) { this.screenLeftCol = screenLeft.col; } @@ -836,7 +838,9 @@ export class SceneProxy { const deltaX = colGroup.attribute.x + colGroup.attribute.width - - (this.table.getAllColsWidth() - this.table.getFrozenColsWidth() - this.table.getRightFrozenColsWidth()); + (this.table.getAllColsWidth() - + (this.table.getFrozenColsContentWidth?.() ?? this.table.getFrozenColsWidth()) - + (this.table.getRightFrozenColsContentWidth?.() ?? this.table.getRightFrozenColsWidth())); this.deltaX = -deltaX; } } else if (isValid(screenLeftX) && isValid(screenLeftCol)) { diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index 344f6ff748..022f2db9bd 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -126,6 +126,15 @@ export class Scenegraph { leftBottomCornerGroup: Group; // 左下角占位单元格Group,只在有下侧冻结行时使用 rightBottomCornerGroup: Group; // 右下角占位单元格Group,只在有右侧下侧都有冻结行时使用 componentGroup: Group; // 表格外组件Group + bodySelectGroup: Group; + rowHeaderSelectGroup: Group; + bottomFrozenSelectGroup: Group; + colHeaderSelectGroup: Group; + rightFrozenSelectGroup: Group; + rightTopCornerSelectGroup: Group; + leftBottomCornerSelectGroup: Group; + rightBottomCornerSelectGroup: Group; + cornerHeaderSelectGroup: Group; /** 所有选中区域对应的选框组件 */ selectedRangeComponents: Map; /** 当前正在选择区域对应的选框组件 为什么是map 以为可能一个选中区域会被拆分为多个rect组件 三块表头和body都分别对应不同组件*/ @@ -341,6 +350,15 @@ export class Scenegraph { delete this.rightBottomCornerGroup.border; this.leftBottomCornerGroup.clear(); delete this.leftBottomCornerGroup.border; + this.bodySelectGroup?.clear(); + this.rowHeaderSelectGroup?.clear(); + this.bottomFrozenSelectGroup?.clear(); + this.colHeaderSelectGroup?.clear(); + this.rightFrozenSelectGroup?.clear(); + this.rightTopCornerSelectGroup?.clear(); + this.leftBottomCornerSelectGroup?.clear(); + this.rightBottomCornerSelectGroup?.clear(); + this.cornerHeaderSelectGroup?.clear(); this.colHeaderGroup.setAttributes({ x: 0, @@ -399,6 +417,65 @@ export class Scenegraph { height: 0, visible: false }); + this.bodySelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0 + }); + this.rowHeaderSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0 + }); + this.bottomFrozenSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0, + visible: false + }); + this.colHeaderSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0 + }); + this.rightFrozenSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0, + visible: false + }); + this.rightTopCornerSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0, + visible: false + }); + this.leftBottomCornerSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0, + visible: false + }); + this.rightBottomCornerSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0, + visible: false + }); + this.cornerHeaderSelectGroup?.setAttributes({ + x: 0, + y: 0, + width: 0, + height: 0 + }); this.tableGroup.setAttributes({ x: this.table.tableX, @@ -1507,8 +1584,21 @@ export class Scenegraph { } this.bodyGroup.setAttribute('y', this.colHeaderGroup.attribute.height + y); this.rowHeaderGroup.setAttribute('y', this.cornerHeaderGroup.attribute.height + y); + this.bodySelectGroup.setAttribute('y', this.bodyGroup.attribute.y); + this.rowHeaderSelectGroup.setAttribute('y', this.rowHeaderGroup.attribute.y); + this.colHeaderSelectGroup.setAttribute('y', this.colHeaderGroup.attribute.y); + this.cornerHeaderSelectGroup.setAttribute('y', this.cornerHeaderGroup.attribute.y); if (this.table.rightFrozenColCount > 0) { this.rightFrozenGroup.setAttribute('y', this.rightTopCornerGroup.attribute.height + y); + this.rightFrozenSelectGroup.setAttribute('y', this.rightFrozenGroup.attribute.y); + this.rightTopCornerSelectGroup.setAttribute('y', this.rightTopCornerGroup.attribute.y); + } + if (this.table.bottomFrozenRowCount > 0) { + this.bottomFrozenSelectGroup.setAttribute('y', this.bottomFrozenGroup.attribute.y); + this.leftBottomCornerSelectGroup.setAttribute('y', this.leftBottomCornerGroup.attribute.y); + } + if (this.table.rightFrozenColCount > 0 && this.table.bottomFrozenRowCount > 0) { + this.rightBottomCornerSelectGroup.setAttribute('y', this.rightBottomCornerGroup.attribute.y); } // this.tableGroup.setAttribute('height', this.table.tableNoFrameHeight - y); // (this.tableGroup.lastChild as any).setAttribute('width', this.table.tableNoFrameWidth - x); @@ -1545,8 +1635,21 @@ export class Scenegraph { } this.bodyGroup.setAttribute('x', this.table.getFrozenColsWidth() + x); this.colHeaderGroup.setAttribute('x', this.table.getFrozenColsWidth() + x); + this.bodySelectGroup.setAttribute('x', this.bodyGroup.attribute.x); + this.colHeaderSelectGroup.setAttribute('x', this.colHeaderGroup.attribute.x); + this.rowHeaderSelectGroup.setAttribute('x', this.rowHeaderGroup.attribute.x); + this.cornerHeaderSelectGroup.setAttribute('x', this.cornerHeaderGroup.attribute.x); if (this.table.bottomFrozenRowCount > 0) { this.bottomFrozenGroup.setAttribute('x', this.table.getFrozenColsWidth() + x); + this.bottomFrozenSelectGroup.setAttribute('x', this.bottomFrozenGroup.attribute.x); + this.leftBottomCornerSelectGroup.setAttribute('x', this.leftBottomCornerGroup.attribute.x); + } + if (this.table.rightFrozenColCount > 0) { + this.rightFrozenSelectGroup.setAttribute('x', this.rightFrozenGroup.attribute.x); + this.rightTopCornerSelectGroup.setAttribute('x', this.rightTopCornerGroup.attribute.x); + } + if (this.table.rightFrozenColCount > 0 && this.table.bottomFrozenRowCount > 0) { + this.rightBottomCornerSelectGroup.setAttribute('x', this.rightBottomCornerGroup.attribute.x); } this.updateNextFrame(); } @@ -1606,6 +1709,11 @@ export class Scenegraph { if (this.table.options.menu?.contextMenuWorkOnlyCell === false) { this.canvasShowMenu(); } + // addRecords / setRecords 等数据变更路径可能会 clearCells + recreate scenegraph, + // 选区组件挂在 overlay 下会被清空,因此需要在场景树重建完成后按 state 重新创建选区图元。 + if (this.table.stateManager.select.ranges?.length) { + this.recreateAllSelectRangeComponents(); + } this.updateNextFrame(); } @@ -1853,9 +1961,17 @@ export class Scenegraph { } updateContainerAttrWidthAndX() { + const frozenStartX = -(this.table.getFrozenColsScrollLeft?.() ?? 0); + const frozenViewportWidth = this.table.getFrozenColsWidth(); + const rightFrozenStartX = + -this.table.getRightFrozenColsOffset() + (this.table.getRightFrozenColsScrollLeft?.() ?? 0); + const rightFrozenContentWidth = this.table.getRightFrozenColsContentWidth(); + // rightFrozenStartX 需要同时考虑“右冻结内容溢出量”与“右冻结滚动位置”: + // - 右冻结内容默认是贴在最右侧的,因此需要先整体向左偏移 offset(使右侧内容尾部对齐视口) + // - 再加上 scrollLeft(在视口内左右移动查看隐藏的列) // 更新各列x&col - const cornerX = updateContainerChildrenX(this.cornerHeaderGroup, 0); - const rowHeaderX = updateContainerChildrenX(this.rowHeaderGroup, 0); + updateContainerChildrenX(this.cornerHeaderGroup, frozenStartX); + updateContainerChildrenX(this.rowHeaderGroup, frozenStartX); const colHeaderX = this.colHeaderGroup.hasChildNodes() && this.colHeaderGroup.firstChild ? updateContainerChildrenX( @@ -1874,10 +1990,9 @@ export class Scenegraph { : 0 ) : 0; - const rightX = updateContainerChildrenX( - this.rightFrozenGroup.childrenCount > 0 ? this.rightFrozenGroup : this.rightTopCornerGroup, - 0 - ); + if (this.rightFrozenGroup.childrenCount > 0) { + updateContainerChildrenX(this.rightFrozenGroup, rightFrozenStartX); + } this.bottomFrozenGroup.hasChildNodes() && this.bottomFrozenGroup.firstChild && @@ -1887,27 +2002,44 @@ export class Scenegraph { ? this.table.getColsWidth(this.table.frozenColCount ?? 0, (this.bottomFrozenGroup.firstChild as any).col - 1) : 0 ); - updateContainerChildrenX(this.leftBottomCornerGroup, 0); - updateContainerChildrenX(this.rightTopCornerGroup, 0); - updateContainerChildrenX(this.rightBottomCornerGroup, 0); + updateContainerChildrenX(this.leftBottomCornerGroup, frozenStartX); + updateContainerChildrenX(this.rightTopCornerGroup, rightFrozenStartX); + updateContainerChildrenX(this.rightBottomCornerGroup, rightFrozenStartX); // 更新容器 - this.cornerHeaderGroup.setDeltaWidth(cornerX - this.cornerHeaderGroup.attribute.width); - this.leftBottomCornerGroup.setDeltaWidth(cornerX - this.leftBottomCornerGroup.attribute.width); + this.cornerHeaderGroup.setDeltaWidth(frozenViewportWidth - this.cornerHeaderGroup.attribute.width); + this.leftBottomCornerGroup.setDeltaWidth(frozenViewportWidth - this.leftBottomCornerGroup.attribute.width); //TODO 可能有影响 this.colHeaderGroup.setDeltaWidth(colHeaderX - this.colHeaderGroup.attribute.width); // this.rightFrozenGroup.setDeltaWidth(colHeaderX - this.table.getRightFrozenColsWidth()); - this.rowHeaderGroup.setDeltaWidth(rowHeaderX - this.rowHeaderGroup.attribute.width); + this.rowHeaderGroup.setDeltaWidth(frozenViewportWidth - this.rowHeaderGroup.attribute.width); this.bottomFrozenGroup.setDeltaWidth(colHeaderX - this.bottomFrozenGroup.attribute.width); - this.rightFrozenGroup.setDeltaWidth(rightX - this.rightFrozenGroup.attribute.width); - this.rightTopCornerGroup.setDeltaWidth(rightX - this.rightTopCornerGroup.attribute.width); - this.rightBottomCornerGroup.setDeltaWidth(rightX - this.rightBottomCornerGroup.attribute.width); + this.rightFrozenGroup.setDeltaWidth(rightFrozenContentWidth - this.rightFrozenGroup.attribute.width); + this.rightTopCornerGroup.setDeltaWidth(rightFrozenContentWidth - this.rightTopCornerGroup.attribute.width); + this.rightBottomCornerGroup.setDeltaWidth(rightFrozenContentWidth - this.rightBottomCornerGroup.attribute.width); this.bodyGroup.setDeltaWidth(bodyX - this.bodyGroup.attribute.width); this.colHeaderGroup.setAttribute('x', this.cornerHeaderGroup.attribute.width); this.bottomFrozenGroup.setAttribute('x', this.table.getFrozenColsWidth()); this.bodyGroup.setAttribute('x', this.rowHeaderGroup.attribute.width); } + setFrozenColsScrollLeft(left: number) { + const frozenStartX = -left; + updateContainerChildrenX(this.cornerHeaderGroup, frozenStartX); + updateContainerChildrenX(this.rowHeaderGroup, frozenStartX); + updateContainerChildrenX(this.leftBottomCornerGroup, frozenStartX); + this.updateNextFrame(); + } + + setRightFrozenColsScrollLeft(left: number) { + // rightStartX 以“右冻结内容右对齐”为基准,再叠加 scrollLeft 在视口内平移 + const rightStartX = -this.table.getRightFrozenColsOffset() + left; + updateContainerChildrenX(this.rightFrozenGroup, rightStartX); + updateContainerChildrenX(this.rightTopCornerGroup, rightStartX); + updateContainerChildrenX(this.rightBottomCornerGroup, rightStartX); + this.updateNextFrame(); + } + updateContainerAttrHeightAndY() { for (let i = 0; i < this.cornerHeaderGroup.children.length; i++) { updateContainerChildrenY(this.cornerHeaderGroup.children[i] as Group, 0); @@ -1990,6 +2122,7 @@ export class Scenegraph { this.updateContainerAttrHeightAndY(); } this.updateTableSize(); + this.syncSelectOverlayGroups(); this.component.updateScrollBar(); // this.updateDomContainer(); @@ -1997,6 +2130,93 @@ export class Scenegraph { this.updateNextFrame(); } + syncSelectOverlayGroups() { + this.bodySelectGroup.setAttributes({ + x: this.bodyGroup.attribute.x, + y: this.bodyGroup.attribute.y, + width: this.bodyGroup.attribute.width, + height: this.bodyGroup.attribute.height + }); + this.rowHeaderSelectGroup.setAttributes({ + x: this.rowHeaderGroup.attribute.x, + y: this.rowHeaderGroup.attribute.y, + width: this.rowHeaderGroup.attribute.width, + height: this.rowHeaderGroup.attribute.height + }); + this.colHeaderSelectGroup.setAttributes({ + x: this.colHeaderGroup.attribute.x, + y: this.colHeaderGroup.attribute.y, + width: this.colHeaderGroup.attribute.width, + height: this.colHeaderGroup.attribute.height + }); + this.cornerHeaderSelectGroup.setAttributes({ + x: this.cornerHeaderGroup.attribute.x, + y: this.cornerHeaderGroup.attribute.y, + width: this.cornerHeaderGroup.attribute.width, + height: this.cornerHeaderGroup.attribute.height + }); + + this.rightFrozenSelectGroup.setAttributes({ + x: this.rightFrozenGroup.attribute.x, + y: this.rightFrozenGroup.attribute.y, + width: this.rightFrozenGroup.attribute.width, + height: this.rightFrozenGroup.attribute.height, + visible: this.rightFrozenGroup.attribute.visible + }); + this.bottomFrozenSelectGroup.setAttributes({ + x: this.bottomFrozenGroup.attribute.x, + y: this.bottomFrozenGroup.attribute.y, + width: this.bottomFrozenGroup.attribute.width, + height: this.bottomFrozenGroup.attribute.height, + visible: this.bottomFrozenGroup.attribute.visible + }); + this.rightTopCornerSelectGroup.setAttributes({ + x: this.rightTopCornerGroup.attribute.x, + y: this.rightTopCornerGroup.attribute.y, + width: this.rightTopCornerGroup.attribute.width, + height: this.rightTopCornerGroup.attribute.height, + visible: this.rightTopCornerGroup.attribute.visible + }); + this.leftBottomCornerSelectGroup.setAttributes({ + x: this.leftBottomCornerGroup.attribute.x, + y: this.leftBottomCornerGroup.attribute.y, + width: this.leftBottomCornerGroup.attribute.width, + height: this.leftBottomCornerGroup.attribute.height, + visible: this.leftBottomCornerGroup.attribute.visible + }); + this.rightBottomCornerSelectGroup.setAttributes({ + x: this.rightBottomCornerGroup.attribute.x, + y: this.rightBottomCornerGroup.attribute.y, + width: this.rightBottomCornerGroup.attribute.width, + height: this.rightBottomCornerGroup.attribute.height, + visible: this.rightBottomCornerGroup.attribute.visible + }); + } + + getSelectOverlayGroup(selectRangeType: CellSubLocation): Group { + switch (selectRangeType) { + case 'body': + return this.bodySelectGroup; + case 'rowHeader': + return this.rowHeaderSelectGroup; + case 'bottomFrozen': + return this.bottomFrozenSelectGroup; + case 'columnHeader': + return this.colHeaderSelectGroup; + case 'rightFrozen': + return this.rightFrozenSelectGroup; + case 'rightTopCorner': + return this.rightTopCornerSelectGroup; + case 'leftBottomCorner': + return this.leftBottomCornerSelectGroup; + case 'rightBottomCorner': + return this.rightBottomCornerSelectGroup; + case 'cornerHeader': + default: + return this.cornerHeaderSelectGroup; + } + } + updateCellContentWhileResize(col: number, row: number) { const isVtableMerge = this.table.getCellRawRecord(col, row)?.vtableMerge; diff --git a/packages/vtable/src/scenegraph/select/create-select-border.ts b/packages/vtable/src/scenegraph/select/create-select-border.ts index 265b67ac0f..8a731ca199 100644 --- a/packages/vtable/src/scenegraph/select/create-select-border.ts +++ b/packages/vtable/src/scenegraph/select/create-select-border.ts @@ -1,9 +1,20 @@ import { createRect } from '@src/vrender'; import type { CellSubLocation } from '../../ts-types'; import type { Scenegraph } from '../scenegraph'; -import { table } from 'console'; import type { BaseTableAPI } from '../../ts-types/base-table'; +/** + * 选框(select border)的创建入口。 + * + * 选框图元并不是直接挂在 tableGroup 下,而是挂在各区域对应的 overlayGroup(select-overlay)下: + * - body / columnHeader / rowHeader / cornerHeader + * - rightFrozen / rightTopCorner / rightBottomCorner + * - bottomFrozen / leftBottomCorner + * + * 这样做的原因: + * - overlay 会参与各区域的 clipRect 裁剪,避免选框越界绘制 + * - 当选择范围跨越多个区域(例如跨表头+body、跨左冻结+body、跨右冻结),需要拆成多段选框分别绘制 + */ export function createCellSelectBorder( scene: Scenegraph, start_Col: number, @@ -15,6 +26,10 @@ export function createCellSelectBorder( strokes: boolean[] // isHasFillHandleRect: boolean ) { + // fill handle(右下角小方块)只在“单一选区 + body 区域 + 允许显示”的场景生效: + // - 多选区时避免出现多个 handle(交互语义不明确),并清理历史 handle + // - 选区包含表头时禁止显示(表头不是可填充的目标) + // - strokes 关闭底边/右边时(例如跨区域拆分后该边由其它段负责),也不应显示 handle let isHasFillHandleRect = !!scene.table.options.excelOptions?.fillHandle; if (scene.table.stateManager.select.ranges?.length > 1) { isHasFillHandleRect = false; @@ -39,6 +54,11 @@ export function createCellSelectBorder( const startRow = Math.min(start_Row, end_Row); const endCol = Math.max(start_Col, end_Col); const endRow = Math.max(start_Row, end_Row); + // overlayGroup 在不同区域会有各自的坐标偏移(例如 columnHeader / body / rightFrozen), + // 选框需要用 global bounds(全局 AABB)减去 tableGroup + overlayGroup 的偏移,换算为 overlay 的本地坐标。 + const overlayGroup = scene.getSelectOverlayGroup(selectRangeType); + const offsetX = scene.tableGroup.attribute.x + (overlayGroup.attribute.x ?? 0); + const offsetY = scene.tableGroup.attribute.y + (overlayGroup.attribute.y ?? 0); const firstCellBound = scene.highPerformanceGetCell(startCol, startRow).globalAABBBounds; const lastCellBound = scene.highPerformanceGetCell(endCol, endRow).globalAABBBounds; const theme = scene.table.theme; @@ -60,8 +80,8 @@ export function createCellSelectBorder( } return false; }), - x: firstCellBound.x1 - scene.tableGroup.attribute.x, // 坐标xy及宽高width height 不需要这里计算具体值 update-select-border文件中updateComponent方法中有逻辑 且该方法调用时间是render - y: firstCellBound.y1 - scene.tableGroup.attribute.y, + x: firstCellBound.x1 - offsetX, + y: firstCellBound.y1 - offsetY, width: 0, height: 0, visible: true, @@ -78,13 +98,14 @@ export function createCellSelectBorder( // 创建右下角小方块 let fillhandle; if (isHasFillHandleRect) { + // 6x6 的 handle,定位在选区右下角(右/下各内缩 3px),避免压住边框线 fillhandle = createRect({ pickable: false, fill: bodyClickBorderColor as string, // lineWidth: bodyClickLineWidth as number, stroke: bodyClickBorderColor as string, // 右下角小方块边框颜色 - x: lastCellBound.x2 - 3, // 调整小方块位置 - y: lastCellBound.y2 - 3, // 调整小方块位置 + x: lastCellBound.x2 - offsetX - 3, + y: lastCellBound.y2 - offsetY - 3, width: 6, height: 6, @@ -92,52 +113,17 @@ export function createCellSelectBorder( }); } scene.lastSelectId = selectId; + // key 以“标准化后的 start/end + selectId”组成:同一选择范围在不同区域拆分后 key 不同, + // 但 selectId 相同,可用于 shift 续选等场景的删除/替换。 scene.selectingRangeComponents.set(`${startCol}-${startRow}-${endCol}-${endRow}-${selectId}`, { rect, fillhandle, role: selectRangeType }); - scene.tableGroup.insertAfter( - rect, - selectRangeType === 'body' - ? scene.bodyGroup - : selectRangeType === 'columnHeader' - ? scene.colHeaderGroup - : selectRangeType === 'rowHeader' - ? scene.rowHeaderGroup - : selectRangeType === 'cornerHeader' - ? scene.cornerHeaderGroup - : selectRangeType === 'rightTopCorner' - ? scene.rightTopCornerGroup - : selectRangeType === 'rightFrozen' - ? scene.rightFrozenGroup - : selectRangeType === 'leftBottomCorner' - ? scene.leftBottomCornerGroup - : selectRangeType === 'bottomFrozen' - ? scene.bottomFrozenGroup - : scene.rightBottomCornerGroup - ); - isHasFillHandleRect && - scene.tableGroup.insertAfter( - fillhandle, - selectRangeType === 'body' - ? scene.bodyGroup - : selectRangeType === 'columnHeader' - ? scene.colHeaderGroup - : selectRangeType === 'rowHeader' - ? scene.rowHeaderGroup - : selectRangeType === 'cornerHeader' - ? scene.cornerHeaderGroup - : selectRangeType === 'rightTopCorner' - ? scene.rightTopCornerGroup - : selectRangeType === 'rightFrozen' - ? scene.rightFrozenGroup - : selectRangeType === 'leftBottomCorner' - ? scene.leftBottomCornerGroup - : selectRangeType === 'bottomFrozen' - ? scene.bottomFrozenGroup - : scene.rightBottomCornerGroup - ); + overlayGroup.addChild(rect); + if (isHasFillHandleRect) { + overlayGroup.addChild(fillhandle); + } } // set corner radius in select rect which covers the corner of the table diff --git a/packages/vtable/src/scenegraph/select/delete-select-border.ts b/packages/vtable/src/scenegraph/select/delete-select-border.ts index c55779f1cb..cb44172f9f 100644 --- a/packages/vtable/src/scenegraph/select/delete-select-border.ts +++ b/packages/vtable/src/scenegraph/select/delete-select-border.ts @@ -4,6 +4,8 @@ import type { CellSubLocation } from '../../ts-types'; /** 按住shift 则继续上次选中范围 需要将现有的删除掉 */ export function deleteLastSelectedRangeComponents(scene: Scenegraph) { + // lastSelectId 用于标识“上一次选择动作”的选区段落(可能拆分为多段,selectId 相同)。 + // shift 续选时,需要移除上一次的选区图元,再将本次选区追加到 selectedRangeComponents。 scene.selectedRangeComponents.forEach( (selectComp: { rect: IRect; fillhandle?: IRect; role: CellSubLocation }, key: string) => { const lastSelectId = key.split('-')[4]; @@ -17,10 +19,10 @@ export function deleteLastSelectedRangeComponents(scene: Scenegraph) { } export function deleteAllSelectBorder(scene: Scenegraph) { + // 清理最终选中态选区(包含 fill handle) scene.selectedRangeComponents.forEach( (selectComp: { rect: IRect; fillhandle?: IRect; role: CellSubLocation }, key: string) => { selectComp.rect.delete(); - selectComp.fillhandle?.delete(); } ); @@ -28,10 +30,10 @@ export function deleteAllSelectBorder(scene: Scenegraph) { } export function deleteAllSelectingBorder(scene: Scenegraph) { + // 清理拖拽/框选过程中的临时选区 scene.selectingRangeComponents.forEach( (selectComp: { rect: IRect; fillhandle?: IRect; role: CellSubLocation }, key: string) => { selectComp.rect.delete(); - selectComp.fillhandle?.delete(); } ); @@ -39,6 +41,7 @@ export function deleteAllSelectingBorder(scene: Scenegraph) { } export function removeFillHandleFromSelectComponents(scene: Scenegraph) { + // 多选区时不展示 fill handle,需要从所有 selectedRangeComponents 中移除 scene.selectedRangeComponents.forEach( (selectComp: { rect: IRect; fillhandle?: IRect; role: CellSubLocation }, key: string) => { selectComp.fillhandle?.delete(); diff --git a/packages/vtable/src/scenegraph/select/move-select-border.ts b/packages/vtable/src/scenegraph/select/move-select-border.ts index cdcb58cab6..4ead094d59 100644 --- a/packages/vtable/src/scenegraph/select/move-select-border.ts +++ b/packages/vtable/src/scenegraph/select/move-select-border.ts @@ -1,6 +1,10 @@ import type { Scenegraph } from '../scenegraph'; export function moveSelectingRangeComponentsToSelectedRangeComponents(scene: Scenegraph) { + // selectingRangeComponents 是拖拽/框选过程中的临时选区; + // 鼠标松开后需要将其“提交”为 selectedRangeComponents,用于后续 hover/滚动时持续更新位置。 + // + // 若 selectedRangeComponents 已存在同 key 的段落(例如重复选择同一区域),先删除旧图元避免泄漏与重复绘制。 scene.selectingRangeComponents.forEach((rangeComponent, key) => { if (scene.selectedRangeComponents.get(key)) { scene.selectingRangeComponents.get(key).rect.delete(); diff --git a/packages/vtable/src/scenegraph/select/update-custom-select-border.ts b/packages/vtable/src/scenegraph/select/update-custom-select-border.ts index 95cb01dde1..b4ae967d3a 100644 --- a/packages/vtable/src/scenegraph/select/update-custom-select-border.ts +++ b/packages/vtable/src/scenegraph/select/update-custom-select-border.ts @@ -12,6 +12,9 @@ export function updateCustomSelectBorder( selectRange: CellRange & { skipBodyMerge?: boolean }, style: CustomSelectionStyle ) { + // 自定义选区(CustomSelectionStyle)与默认选区的拆分方式一致: + // 仍然需要按 corner/headers/body/rightFrozen/bottomFrozen 等区域拆分绘制, + // 以便配合各区域的 clipRect,避免跨区域绘制导致的越界或重复描边。 const table = scene.table; const newStartCol = selectRange.start.col; const newStartRow = selectRange.start.row; @@ -200,10 +203,14 @@ function createCustomCellSelectBorder( strokes: boolean[], style: CustomSelectionStyle ) { + // 自定义选区只负责绘制 rect(不包含 fill handle),其 key 与默认选区保持一致,便于统一更新/删除。 const startCol = Math.min(start_Col, end_Col); const startRow = Math.min(start_Row, end_Row); const endCol = Math.max(start_Col, end_Col); const endRow = Math.max(start_Row, end_Row); + const overlayGroup = scene.getSelectOverlayGroup(selectRangeType); + const offsetX = scene.tableGroup.attribute.x + (overlayGroup.attribute.x ?? 0); + const offsetY = scene.tableGroup.attribute.y + (overlayGroup.attribute.y ?? 0); const firstCellBound = scene.highPerformanceGetCell(startCol, startRow).globalAABBBounds; const rect = createRect({ pickable: false, @@ -216,8 +223,8 @@ function createCustomCellSelectBorder( } return false; }), - x: firstCellBound.x1 - scene.tableGroup.attribute.x, // 坐标xy及宽高width height 不需要这里计算具体值 update-select-border文件中updateComponent方法中有逻辑 且该方法调用时间是render - y: firstCellBound.y1 - scene.tableGroup.attribute.y, + x: firstCellBound.x1 - offsetX, + y: firstCellBound.y1 - offsetY, width: 0, height: 0, visible: true, @@ -235,24 +242,5 @@ function createCustomCellSelectBorder( rect, role: selectRangeType }); - scene.tableGroup.insertAfter( - rect, - selectRangeType === 'body' - ? scene.bodyGroup - : selectRangeType === 'columnHeader' - ? scene.colHeaderGroup - : selectRangeType === 'rowHeader' - ? scene.rowHeaderGroup - : selectRangeType === 'cornerHeader' - ? scene.cornerHeaderGroup - : selectRangeType === 'rightTopCorner' - ? scene.rightTopCornerGroup - : selectRangeType === 'rightFrozen' - ? scene.rightFrozenGroup - : selectRangeType === 'leftBottomCorner' - ? scene.leftBottomCornerGroup - : selectRangeType === 'bottomFrozen' - ? scene.bottomFrozenGroup - : scene.rightBottomCornerGroup - ); + overlayGroup.addChild(rect); } diff --git a/packages/vtable/src/scenegraph/select/update-select-border.ts b/packages/vtable/src/scenegraph/select/update-select-border.ts index f840986b45..23685e1b1b 100644 --- a/packages/vtable/src/scenegraph/select/update-select-border.ts +++ b/packages/vtable/src/scenegraph/select/update-select-border.ts @@ -6,6 +6,10 @@ import { calculateCellRangeDistribution } from '../utils/cell-pos'; import { TABLE_EVENT_TYPE } from '../../core/TABLE_EVENT_TYPE'; export function updateAllSelectComponent(scene: Scenegraph) { + // 三类选区组件需要统一更新: + // - customSelectedRangeComponents:自定义样式选区(独立于默认 selectionStyle) + // - selectingRangeComponents:拖拽/框选过程中的临时选区 + // - selectedRangeComponents:最终选中态选区 scene.customSelectedRangeComponents.forEach((selectComp: { rect: IRect; role: CellSubLocation }, key: string) => { updateComponent(selectComp, key, scene); }); @@ -28,6 +32,7 @@ function updateComponent( scene: Scenegraph ) { const table = scene.table; + // key: `${startCol}-${startRow}-${endCol}-${endRow}-${selectId}` const [startColStr, startRowStr, endColStr, endRowStr] = key.split('-'); const startCol = parseInt(startColStr, 10); const startRow = parseInt(startRowStr, 10); @@ -42,6 +47,7 @@ function updateComponent( let visibleCellRange; switch (selectComp.role) { case 'rowHeader': + // rowHeader 的选区高度跟随 body 可视行范围(避免选框覆盖不可见区域造成性能浪费) visibleCellRange = table.getBodyVisibleRowRange(); if (visibleCellRange) { computeRectCellRangeStartRow = Math.max(startRow, visibleCellRange.rowStart - 1); @@ -49,6 +55,7 @@ function updateComponent( } break; case 'columnHeader': + // columnHeader 的选区宽度跟随 body 可视列范围 visibleCellRange = table.getBodyVisibleCellRange(); if (visibleCellRange) { computeRectCellRangeStartCol = Math.max(startCol, visibleCellRange.colStart - 1); @@ -58,6 +65,7 @@ function updateComponent( case 'cornerHeader': break; case 'bottomFrozen': + // bottomFrozen 的横向范围依赖 body 的可视列范围 visibleCellRange = table.getBodyVisibleCellRange(); if (visibleCellRange) { computeRectCellRangeStartCol = Math.max(startCol, visibleCellRange.colStart - 1); @@ -65,6 +73,7 @@ function updateComponent( } break; case 'rightFrozen': + // rightFrozen 的纵向范围依赖 body 的可视行范围 visibleCellRange = table.getBodyVisibleCellRange(); if (visibleCellRange) { computeRectCellRangeStartRow = Math.max(startRow, visibleCellRange.rowStart - 1); @@ -78,6 +87,7 @@ function updateComponent( case 'rightBottomCorner': break; default: + // body 区选框默认同时裁剪行列范围 visibleCellRange = table.getBodyVisibleCellRange(); if (visibleCellRange) { computeRectCellRangeStartRow = Math.max(startRow, visibleCellRange.rowStart - 1); @@ -93,14 +103,19 @@ function updateComponent( const colsWidth = table.getColsWidth(computeRectCellRangeStartCol, computeRectCellRangeEndCol); const rowsHeight = table.getRowsHeight(computeRectCellRangeStartRow, computeRectCellRangeEndRow); + const overlayGroup = scene.getSelectOverlayGroup(selectComp.role); + const offsetX = scene.tableGroup.attribute.x + (overlayGroup.attribute.x ?? 0); + const offsetY = scene.tableGroup.attribute.y + (overlayGroup.attribute.y ?? 0); + + // 使用第一个单元格的 global bounds 来确定选框左上角,换算为 overlay 本地坐标。 const firstCellBound = scene.highPerformanceGetCell( computeRectCellRangeStartCol, computeRectCellRangeStartRow ).globalAABBBounds; selectComp.rect.setAttributes({ - x: firstCellBound.x1 - scene.tableGroup.attribute.x, //坐标xy在下面的逻辑中会做适当调整 - y: firstCellBound.y1 - scene.tableGroup.attribute.y, + x: firstCellBound.x1 - offsetX, + y: firstCellBound.y1 - offsetY, width: colsWidth, height: rowsHeight, visible: true @@ -122,22 +137,25 @@ function updateComponent( } //#region 计算填充柄小方块的位置 + // fill handle 的定位基于“选区右下角单元格”的 global bounds。 + // 当选区贴边(最后一列/最后一行)时,需要用相邻单元格 bounds 来推导一个合理的 handle 位置, + // 否则 handle 会超出 table/clip 区域导致不可见或命中异常。 let lastCellBound; let handlerX; //当选择区域没有到到最后一列时 if (endCol < table.colCount - 1) { lastCellBound = scene.highPerformanceGetCell(endCol, endRow).globalAABBBounds; - handlerX = lastCellBound.x2 - scene.tableGroup.attribute.x - 3; + handlerX = lastCellBound.x2 - offsetX - 3; } else { // 最后一列 // computeRectCellRangeStartCol 而且是第一列时 if (startCol === 0) { //解决issue #4376 但还是有问题当存在冻结的时候。以及需要处理类似情况下面逻辑最后一行的情况 lastCellBound = scene.highPerformanceGetCell(0, endRow).globalAABBBounds; - handlerX = lastCellBound.x1 - scene.tableGroup.attribute.x; + handlerX = lastCellBound.x1 - offsetX; } else { lastCellBound = scene.highPerformanceGetCell(startCol - 1, endRow).globalAABBBounds; - handlerX = lastCellBound.x2 - scene.tableGroup.attribute.x - 3; + handlerX = lastCellBound.x2 - offsetX - 3; } } // const handlerX = lastCellBound.x2 - scene.tableGroup.attribute.x - 3; @@ -148,7 +166,7 @@ function updateComponent( // 最后一行 lastCellBound = scene.highPerformanceGetCell(endCol, startRow - 1).globalAABBBounds; } - const handlerY = lastCellBound.y2 - scene.tableGroup.attribute.y - 3; + const handlerY = lastCellBound.y2 - offsetY - 3; //#endregion selectComp.fillhandle?.setAttributes({ @@ -160,181 +178,9 @@ function updateComponent( }); } - //#region 判断是不是按着表头部分的选中框 因为绘制层级的原因 线宽会被遮住一半,因此需要动态调整层级 - let isNearRowHeader = table.frozenColCount ? startCol === table.frozenColCount : false; - if (!isNearRowHeader && table.frozenColCount && table.scrollLeft > 0 && startCol >= table.frozenColCount) { - const startColRelativePosition = table.getColsWidth(0, startCol - 1) - table.scrollLeft; - if (startColRelativePosition < table.getFrozenColsWidth()) { - isNearRowHeader = true; - } - } - - let isNearRightRowHeader = table.rightFrozenColCount - ? table.rightFrozenColCount > 0 && endCol === table.colCount - table.rightFrozenColCount - 1 - : false; - if (!isNearRightRowHeader && table.rightFrozenColCount && endCol < table.colCount - table.rightFrozenColCount) { - const endColRelativePosition = table.getColsWidth(0, endCol) - table.scrollLeft; - if (endColRelativePosition > table.tableNoFrameWidth - table.getRightFrozenColsWidth()) { - isNearRightRowHeader = true; - } - } - - let isNearColHeader = table.frozenRowCount ? startRow === table.frozenRowCount : true; - if (!isNearColHeader && table.frozenRowCount && table.scrollTop > 0 && startRow >= table.frozenRowCount) { - const startRowRelativePosition = table.getRowsHeight(0, startRow - 1) - table.scrollTop; - if (startRowRelativePosition < table.getFrozenRowsHeight()) { - isNearColHeader = true; - } - } - - let isNearBottomColHeader = table.bottomFrozenRowCount - ? endRow === table.rowCount - table.bottomFrozenRowCount - 1 - : false; - if (!isNearBottomColHeader && table.bottomFrozenRowCount && endRow < table.rowCount - table.bottomFrozenRowCount) { - const endRowRelativePosition = table.getRowsHeight(0, endRow) - table.scrollTop; - if (endRowRelativePosition > table.tableNoFrameHeight - table.getBottomFrozenRowsHeight()) { - isNearBottomColHeader = true; - } - } - - const { dynamicUpdateSelectionSize } = table.theme.selectionStyle; - if ( - (isNearRowHeader && (selectComp.rect.attribute.stroke[3] || dynamicUpdateSelectionSize)) || - (isNearRightRowHeader && (selectComp.rect.attribute.stroke[1] || dynamicUpdateSelectionSize)) || - (isNearColHeader && (selectComp.rect.attribute.stroke[0] || dynamicUpdateSelectionSize)) || - (isNearBottomColHeader && (selectComp.rect.attribute.stroke[2] || dynamicUpdateSelectionSize)) - ) { - if (isNearRowHeader && selectComp.rect.attribute.stroke[3]) { - scene.tableGroup.insertAfter( - selectComp.rect, - selectComp.role === 'columnHeader' - ? scene.cornerHeaderGroup - : selectComp.role === 'bottomFrozen' - ? scene.leftBottomCornerGroup - : scene.rowHeaderGroup - ); - } - - if (isNearBottomColHeader && selectComp.rect.attribute.stroke[2]) { - scene.tableGroup.insertAfter( - selectComp.rect, - selectComp.role === 'rowHeader' - ? scene.leftBottomCornerGroup - : selectComp.role === 'rightFrozen' - ? scene.rightBottomCornerGroup - : scene.bottomFrozenGroup - ); - } - - if (isNearColHeader && selectComp.rect.attribute.stroke[0]) { - scene.tableGroup.insertAfter( - selectComp.rect, - selectComp.role === 'rowHeader' - ? scene.cornerHeaderGroup - : selectComp.role === 'rightFrozen' - ? scene.rightTopCornerGroup - : scene.colHeaderGroup - ); - } - if (isNearRightRowHeader && selectComp.rect.attribute.stroke[1]) { - scene.tableGroup.insertAfter( - selectComp.rect, - selectComp.role === 'columnHeader' - ? scene.rightTopCornerGroup - : selectComp.role === 'bottomFrozen' - ? scene.rightBottomCornerGroup - : scene.rightFrozenGroup - ); - } - - //#region 调整层级后 滚动情况下会出现绘制范围出界 如body的选中框 渲染在了rowheader上面,所有需要调整选中框rect的 边界 - if ( - selectComp.rect.attribute.x < table.getFrozenColsWidth() && - // selectComp.rect.attribute.x + selectComp.rect.attribute.width > scene.rowHeaderGroup.attribute.width && - table.scrollLeft > 0 && - (selectComp.role === 'body' || selectComp.role === 'columnHeader' || selectComp.role === 'bottomFrozen') - ) { - const width = selectComp.rect.attribute.width - (table.getFrozenColsWidth() - selectComp.rect.attribute.x); - selectComp.rect.setAttributes({ - x: selectComp.rect.attribute.x + (table.getFrozenColsWidth() - selectComp.rect.attribute.x), - width: width > 0 ? width : 0 - }); - selectComp.fillhandle?.setAttributes({ - visible: width > 0 - }); - } - if ( - // selectComp.rect.attribute.x < scene.rightFrozenGroup.attribute.x && - table.getRightFrozenColsWidth() > 0 && // right冻结列存在的情况下 - scene.rightFrozenGroup.attribute.height > 0 && - selectComp.rect.attribute.x + selectComp.rect.attribute.width > scene.rightFrozenGroup.attribute.x && - (selectComp.role === 'body' || selectComp.role === 'columnHeader' || selectComp.role === 'bottomFrozen') - ) { - const width = scene.rightFrozenGroup.attribute.x - selectComp.rect.attribute.x; - selectComp.rect.setAttributes({ - x: selectComp.rect.attribute.x, - width: width > 0 ? width : 0 - }); - selectComp.fillhandle?.setAttributes({ - visible: width - colsWidth > 0 - }); - } - if ( - selectComp.rect.attribute.y < scene.colHeaderGroup.attribute.height && - table.scrollTop > 0 && - (selectComp.role === 'body' || selectComp.role === 'rowHeader' || selectComp.role === 'rightFrozen') - ) { - const height = - selectComp.rect.attribute.height - (scene.colHeaderGroup.attribute.height - selectComp.rect.attribute.y); - selectComp.rect.setAttributes({ - y: selectComp.rect.attribute.y + (scene.colHeaderGroup.attribute.height - selectComp.rect.attribute.y), - height: height > 0 ? height : 0 - }); - selectComp.fillhandle?.setAttributes({ - visible: height > 0 - }); - } - if ( - scene.bottomFrozenGroup.attribute.width > 0 && - scene.bottomFrozenGroup.attribute.height > 0 && - selectComp.rect.attribute.y + selectComp.rect.attribute.height > scene.bottomFrozenGroup.attribute.y && - (selectComp.role === 'body' || selectComp.role === 'rowHeader' || selectComp.role === 'rightFrozen') - ) { - const height = scene.bottomFrozenGroup.attribute.y - selectComp.rect.attribute.y; - selectComp.rect.setAttributes({ - y: selectComp.rect.attribute.y, - height: height > 0 ? height : 0 - }); - selectComp.fillhandle?.setAttributes({ - visible: height - rowsHeight > 0 - }); - } - //#endregion - } else { - scene.tableGroup.insertAfter( - selectComp.rect, - selectComp.role === 'body' - ? scene.bodyGroup - : selectComp.role === 'columnHeader' - ? scene.colHeaderGroup - : selectComp.role === 'rowHeader' - ? scene.rowHeaderGroup - : selectComp.role === 'cornerHeader' - ? scene.cornerHeaderGroup - : selectComp.role === 'rightTopCorner' - ? scene.rightTopCornerGroup - : selectComp.role === 'rightFrozen' - ? scene.rightFrozenGroup - : selectComp.role === 'leftBottomCorner' - ? scene.leftBottomCornerGroup - : selectComp.role === 'bottomFrozen' - ? scene.bottomFrozenGroup - : scene.rightBottomCornerGroup - ); - } - //#endregion - //#region 处理边缘被截问题 + // 选框边框线绘制在 rect 边缘的中心(half line width),当选区贴着表格最外边界时, + // 有一半线宽会被 clip 或 canvas 边界裁掉。这里通过收缩/偏移 rect 的宽高来避免“半条线”效果。 let diffSize = 0; if (typeof selectComp.rect.attribute.lineWidth === 'number') { diffSize = Math.ceil(selectComp.rect.attribute.lineWidth / 2); @@ -408,6 +254,8 @@ export function updateCellSelectBorder( let endRow = Math.min(Math.max(newEndRow, newStartRow), table.rowCount - 1); //#region region 校验四周的单元格有没有合并的情况,如有则扩大范围 const extendSelectRange = () => { + // 若选区边缘触达合并单元格的一部分,需要扩大选区以覆盖整个 merge block。 + // 这里采用递归扩展:一旦发生扩展,继续检查新边界直到收敛。 let isExtend = false; for (let col = startCol; col <= endCol; col++) { if (col === startCol) { @@ -517,6 +365,9 @@ export function updateCellSelectBorder( needRightBottomCornerHeader } = calculateCellRangeDistribution(startCol, startRow, endCol, endRow, table); + // 选区可能跨越多个区域(例如 cornerHeader/columnHeader/body/rightFrozen/bottomFrozen 等), + // 需要按区域拆分成多个 select border。每段的 strokeArray 用来控制四条边是否绘制, + // 避免跨区域边界处出现“重复描边”(两段都画同一条边会显得更粗)。 // TODO 可以尝试不拆分三个表头和body【前提是theme中合并配置】 用一个SelectBorder 需要结合clip,并动态设置border的范围【依据区域范围 已经是否跨表头及body】 if (needCornerHeader) { const cornerEndCol = Math.min(endCol, table.frozenColCount - 1); diff --git a/packages/vtable/src/scenegraph/select/update-select-style.ts b/packages/vtable/src/scenegraph/select/update-select-style.ts index 072562f451..a14c869878 100644 --- a/packages/vtable/src/scenegraph/select/update-select-style.ts +++ b/packages/vtable/src/scenegraph/select/update-select-style.ts @@ -3,6 +3,8 @@ import type { Scenegraph } from '../scenegraph'; // for fs big screen export function temporarilyUpdateSelectRectStyle(rectAttribute: IRectGraphicAttribute, scene: Scenegraph) { + // 临时覆盖选区样式(例如大屏模式下增强可见性)。 + // 该方法只更新最终选中态(selectedRangeComponents),不会影响 selectingRangeComponents(拖拽过程态)。 const { selectedRangeComponents } = scene; selectedRangeComponents.forEach((selectComp: { rect: IRect }, key: string) => { selectComp.rect.setAttributes(rectAttribute); diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index ed89875b2c..51ae5ba3cb 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -179,6 +179,10 @@ export class StateManager { scroll: { horizontalBarPos: number; verticalBarPos: number; + // 左侧冻结区域内部横向滚动位置(单位:px)。仅在 scrollFrozenCols 开启且存在溢出时生效。 + frozenHorizontalBarPos: number; + // 右侧冻结区域内部横向滚动位置(单位:px)。仅在 scrollRightFrozenCols 开启且存在溢出时生效。 + rightFrozenHorizontalBarPos: number; }; tablePosition: { absoluteX: number; @@ -229,6 +233,8 @@ export class StateManager { this.updateVerticalScrollBar = this.updateVerticalScrollBar.bind(this); this.updateHorizontalScrollBar = this.updateHorizontalScrollBar.bind(this); + this.updateFrozenHorizontalScrollBar = this.updateFrozenHorizontalScrollBar.bind(this); + this.updateRightFrozenHorizontalScrollBar = this.updateRightFrozenHorizontalScrollBar.bind(this); } initState() { @@ -398,7 +404,9 @@ export class StateManager { }; this.scroll = { horizontalBarPos: 0, - verticalBarPos: 0 + verticalBarPos: 0, + frozenHorizontalBarPos: 0, + rightFrozenHorizontalBarPos: 0 }; this.tablePosition = { absoluteX: 0, @@ -982,7 +990,12 @@ export class StateManager { const maxFrozenWidth = this.table._getMaxFrozenWidth(); if (frozenWidth > maxFrozenWidth) { - if (this.table.internalProps.unfreezeAllOnExceedsMaxWidth) { + if (this.table.options.scrollFrozenCols) { + if (this.table.frozenColCount !== originalFrozenColCount) { + this.table._setFrozenColCount(originalFrozenColCount); + this.setFrozenCol(originalFrozenColCount); + } + } else if (this.table.internalProps.unfreezeAllOnExceedsMaxWidth) { this.table._setFrozenColCount(0); this.setFrozenCol(-1); } else { @@ -994,11 +1007,77 @@ export class StateManager { this.table._setFrozenColCount(originalFrozenColCount); this.setFrozenCol(originalFrozenColCount); } + if (!this.table.options.scrollFrozenCols || this.table.getFrozenColsOffset() === 0) { + this.setFrozenColsScrollLeft(0, false); + } else { + this.setFrozenColsScrollLeft(this.scroll.frozenHorizontalBarPos, false); + } + if (!this.table.options.scrollRightFrozenCols || this.table.getRightFrozenColsOffset() === 0) { + this.setRightFrozenColsScrollLeft(0, false); + } else { + this.setRightFrozenColsScrollLeft(this.scroll.rightFrozenHorizontalBarPos, false); + } } else { this.clearFrozenObserver(); } } + setFrozenColsScrollLeft(left: number, triggerRender: boolean = true) { + if (!this.table || !this.table.scenegraph) { + return; + } + const maxScrollLeft = this.table.getFrozenColsOffset(); + left = Math.max(0, Math.min(left, maxScrollLeft)); + left = Math.ceil(left); + if (this.scroll.frozenHorizontalBarPos === left) { + return; + } + this.scroll.frozenHorizontalBarPos = left; + // 左冻结滚动条的 0~1 比例与 scrollLeft 同向:ratio = left / maxScrollLeft + const ratio = maxScrollLeft ? left / maxScrollLeft : 0; + this.table.scenegraph.component.updateFrozenHorizontalScrollBarPos(ratio); + triggerRender && this.table.scenegraph.setFrozenColsScrollLeft(left); + } + + setRightFrozenColsScrollLeft(left: number, triggerRender: boolean = true) { + if (!this.table || !this.table.scenegraph) { + return; + } + const maxScrollLeft = this.table.getRightFrozenColsOffset(); + left = Math.max(0, Math.min(left, maxScrollLeft)); + left = Math.ceil(left); + if (this.scroll.rightFrozenHorizontalBarPos === left) { + return; + } + this.scroll.rightFrozenHorizontalBarPos = left; + // 右冻结的视觉“展开方向”与 left 值相反(right frozen 的内容从右往左展开)。 + // 为了让滚动条 thumb 的移动方向更符合直觉,这里将滚动条 ratio 做反向映射: + // ratio = 1 - left / maxScrollLeft + const ratio = maxScrollLeft ? 1 - left / maxScrollLeft : 1; + this.table.scenegraph.component.updateRightFrozenHorizontalScrollBarPos(ratio); + triggerRender && this.table.scenegraph.setRightFrozenColsScrollLeft(left); + } + + updateFrozenHorizontalScrollBar(xRatio: number) { + const maxScrollLeft = this.table.getFrozenColsOffset?.() ?? 0; + // 由滚动条 ratio 反推左冻结 scrollLeft(同向) + let left = Math.ceil(xRatio * maxScrollLeft); + if (!isValid(left) || isNaN(left)) { + left = 0; + } + this.setFrozenColsScrollLeft(left, true); + } + + updateRightFrozenHorizontalScrollBar(xRatio: number) { + const maxScrollLeft = this.table.getRightFrozenColsOffset?.() ?? 0; + // 由滚动条 ratio 反推右冻结 scrollLeft(反向) + let left = Math.ceil((1 - xRatio) * maxScrollLeft); + if (!isValid(left) || isNaN(left)) { + left = 0; + } + this.setRightFrozenColsScrollLeft(left, true); + } + clearFrozenObserver() { // 清理观察器 if (this._frozenObserver) { @@ -1124,8 +1203,11 @@ export class StateManager { updateHorizontalScrollBar(xRatio: number) { const totalWidth = this.table.getAllColsWidth(); const oldHorizontalBarPos = this.scroll.horizontalBarPos; + const frozenOffset = this.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = this.table.getRightFrozenColsOffset?.() ?? 0; + const scrollRange = Math.max(0, totalWidth - this.table.scenegraph.width - frozenOffset - rightFrozenOffset); - let horizontalBarPos = Math.ceil(xRatio * (totalWidth - this.table.scenegraph.width)); + let horizontalBarPos = Math.ceil(xRatio * scrollRange); if (!isValid(horizontalBarPos) || isNaN(horizontalBarPos)) { horizontalBarPos = 0; } @@ -1147,7 +1229,7 @@ export class StateManager { if (canScroll.some(value => value === false)) { // reset scrollbar pos - const xRatio = this.scroll.horizontalBarPos / (totalWidth - this.table.scenegraph.width); + const xRatio = scrollRange ? this.scroll.horizontalBarPos / scrollRange : 0; this.table.scenegraph.component.updateHorizontalScrollBarPos(xRatio); return; } @@ -1273,7 +1355,9 @@ export class StateManager { const oldScrollLeft = this.table.scrollLeft; // 矫正left值范围 const totalWidth = this.table.getAllColsWidth(); - const frozenWidth = this.table.getFrozenColsWidth(); + const frozenOffset = this.table.getFrozenColsOffset?.() ?? 0; + const rightFrozenOffset = this.table.getRightFrozenColsOffset?.() ?? 0; + const scrollRange = Math.max(0, totalWidth - this.table.scenegraph.width - frozenOffset - rightFrozenOffset); // _disableColumnAndRowSizeRound环境中,可能出现 // getAllColsWidth/getAllRowsHeight(A) + getAllColsWidth/getAllRowsHeight(B) < getAllColsWidth/getAllRowsHeight(A+B) @@ -1281,10 +1365,10 @@ export class StateManager { // 这里加入tolerance,避免出现无用滚动 const sizeTolerance = this.table.options.customConfig?._disableColumnAndRowSizeRound ? 1 : 0; - left = Math.max(0, Math.min(left, totalWidth - this.table.scenegraph.width - sizeTolerance)); + left = Math.max(0, Math.min(left, scrollRange - sizeTolerance)); left = Math.ceil(left); const oldHorizontalBarPos = this.scroll.horizontalBarPos; - const xRatio = left / (totalWidth - this.table.scenegraph.width); + const xRatio = scrollRange ? left / scrollRange : 0; // if (oldHorizontalBarPos !== left && triggerEvent) { if ( @@ -1311,7 +1395,7 @@ export class StateManager { if (canScroll.some(value => value === false)) { // reset scrollbar pos - const xRatio = this.scroll.horizontalBarPos / (totalWidth - this.table.scenegraph.width); + const xRatio = scrollRange ? this.scroll.horizontalBarPos / scrollRange : 0; this.table.scenegraph.component.updateHorizontalScrollBarPos(xRatio); return; } @@ -1356,9 +1440,9 @@ export class StateManager { } showVerticalScrollBar(autoHide?: boolean) { this.table.scenegraph.component.showVerticalScrollBar(); + clearTimeout(this._clearVerticalScrollBar); if (autoHide) { // 滚轮触发滚动条显示后,异步隐藏 - clearTimeout(this._clearVerticalScrollBar); this._clearVerticalScrollBar = setTimeout(() => { this.table.scenegraph?.component.hideVerticalScrollBar(); }, 1000); @@ -1367,12 +1451,12 @@ export class StateManager { hideHorizontalScrollBar() { this.table.scenegraph.component.hideHorizontalScrollBar(); } - showHorizontalScrollBar(autoHide?: boolean) { - this.table.scenegraph.component.showHorizontalScrollBar(); + showHorizontalScrollBar(autoHide?: boolean, target: 'body' | 'frozen' | 'rightFrozen' | 'all' = 'all') { + this.table.scenegraph.component.showHorizontalScrollBar(target); this.table.scenegraph?.component.showFrozenColumnShadow(); + clearTimeout(this._clearHorizontalScrollBar); if (autoHide) { // 滚轮触发滚动条显示后,异步隐藏 - clearTimeout(this._clearHorizontalScrollBar); this._clearHorizontalScrollBar = setTimeout(() => { this.table.scenegraph?.component.hideFrozenColumnShadow(); this.table.scenegraph?.component.hideHorizontalScrollBar(); diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index 932a8bfcb4..4c9811d85c 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -317,8 +317,48 @@ export interface BaseTableConstructorOptions { bottomFrozenRowCount?: number; /** 最大冻结宽度,固定值 or 百分比。默认为'80%' */ maxFrozenWidth?: number | string; + /** + * 右侧最大冻结宽度,固定值 or 百分比。 + * + * - 仅在 `rightFrozenColCount > 0` 时有意义 + * - 默认与 `maxFrozenWidth` 保持一致(便于左右冻结行为对齐) + * - 当 `scrollRightFrozenCols` 开启时,该值决定右侧冻结区域的“视口宽度上限” + */ + maxRightFrozenWidth?: number | string; /** 超过最大冻结宽度后是否全部解冻,默认true */ unfreezeAllOnExceedsMaxWidth?: boolean; + /** + * 是否允许左侧冻结区域内部横向滚动。 + * + * 当左侧冻结列的“内容总宽度”超过 `maxFrozenWidth` 时: + * - `false`(默认):冻结列会按 `unfreezeAllOnExceedsMaxWidth` 的策略自动解冻以适配视口 + * - `true`:保留全部冻结列,并在左侧冻结区域内通过触摸板横向滚动/滚动条查看超出部分 + * + * 该能力会引入一个独立的滚动域(frozen),对应 `getFrozenColsScrollLeft/getFrozenColsOffset`。 + */ + scrollFrozenCols?: boolean; + /** + * 是否允许右侧冻结区域内部横向滚动。 + * + * 当右侧冻结列的“内容总宽度”超过 `maxRightFrozenWidth` 时: + * - `false`(默认):右侧冻结区域宽度等于内容宽度(不会出现内部横向滚动) + * - `true`:保留全部右侧冻结列,并在右侧冻结区域内通过触摸板横向滚动/滚动条查看超出部分 + * + * 该能力会引入一个独立的滚动域(rightFrozen),对应 `getRightFrozenColsScrollLeft/getRightFrozenColsOffset`。 + */ + scrollRightFrozenCols?: boolean; + + /** + * 冻结区域滚动到边界时,是否自动“透传”给 body 横向滚动。 + * + * - `false`(默认):在冻结区域内滚动时,即使滚动到头/尾也不会触发 body 横向滚动 + * - `true`:当冻结区域无法继续滚动时,将剩余滚动意图交由 body 横向滚动处理 + * + * 说明: + * - 仅对鼠标滚轮/触摸板触发的横向滚动(wheel)生效 + * - 仅在 `scrollFrozenCols` / `scrollRightFrozenCols` 开启且对应区域存在溢出(offset > 0)时才有意义 + */ + scrollFrozenColsPassThroughToBody?: boolean; // /** 待实现 TODO */ // frozenRowCount?: number; @@ -851,8 +891,14 @@ export interface BaseTableAPI { getFrozenRowsHeight: () => number; getFrozenColsWidth: () => number; + getFrozenColsContentWidth: () => number; + getFrozenColsOffset: () => number; + getFrozenColsScrollLeft: () => number; getBottomFrozenRowsHeight: () => number; getRightFrozenColsWidth: () => number; + getRightFrozenColsContentWidth: () => number; + getRightFrozenColsOffset: () => number; + getRightFrozenColsScrollLeft: () => number; selectCell: ( col: number, row: number,