Skip to content

Commit d57599a

Browse files
committed
docs: add code abbitation and technical docs
1 parent 9b4d77b commit d57599a

19 files changed

Lines changed: 495 additions & 3 deletions
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# 冻结区域独立横向滚动:技术设计
2+
3+
本文总结 VTable 中“冻结区域(左冻结/右冻结)支持独立横向滚动”的实现设计、核心数据结构、关键链路改动点与边界行为。
4+
5+
> 对应实现主要落在 packages/vtable/src 内;PR 参考:#5063(Feat/frozen column scroll)。
6+
7+
## 1. 背景与问题
8+
9+
在 ListTable 中,冻结列用于保持关键列常驻可见。但当冻结列总宽度超过最大冻结宽度(`maxFrozenWidth`)时,既有策略通常是“自动解冻部分列”以满足视口宽度限制。
10+
11+
在一些业务场景中,冻结列必须保留(例如左侧关键标识列、右侧操作列),因此需要:
12+
13+
- 冻结区域在宽度受限时仍保留全部冻结列
14+
- 在冻结区域内部提供横向滚动能力(与 body 横向滚动相互独立)
15+
- 在多滚动域场景下,滚动条显隐与交互需要“按区域”工作,避免混乱
16+
17+
## 2. 目标与非目标
18+
19+
### 2.1 目标
20+
21+
- 新增左/右冻结区域内部横向滚动能力(trackpad/wheel 与滚动条拖拽/点击轨道)
22+
- 保持冻结区域“视口宽度”可控(左:`maxFrozenWidth`,右:`maxRightFrozenWidth`
23+
- 多滚动域并存时(body / leftFrozen / rightFrozen),保证:
24+
- 渲染坐标系正确(内容随各自 scrollLeft 平移)
25+
- 命中坐标正确(getCellAt、事件 target 等与渲染一致)
26+
- 主滚动条与冻结滚动条互不干扰
27+
- `scrollStyle.visible``focus/scrolling/always/none` 时行为一致
28+
29+
### 2.2 非目标
30+
31+
- 本设计不覆盖 PivotTable/Gantt 的冻结滚动扩展(当前以 ListTable 为主)
32+
- 不引入插件化实现(该能力需要穿透状态/布局/命中/scenegraph 多层链路)
33+
34+
## 3. 概念与术语
35+
36+
- **冻结内容宽(content width)**:冻结列本身的总宽度,不受 maxFrozenWidth 限制。
37+
- **冻结视口宽(viewport width)**:冻结区域在画布上占用的宽度,受 maxFrozenWidth / maxRightFrozenWidth 限制。
38+
- **冻结溢出量(offset)**`max(0, contentWidth - viewportWidth)`,表示冻结区域内部最大可滚动距离。
39+
- **滚动域(scroll domain)**
40+
- `body`:主横向滚动域(影响非冻结列)
41+
- `frozen`:左冻结区域内部横向滚动域
42+
- `rightFrozen`:右冻结区域内部横向滚动域
43+
44+
## 4. 对外配置与 API
45+
46+
### 4.1 新增配置(ListTableConstructorOptions)
47+
48+
- `scrollFrozenCols?: boolean`
49+
- `false`(默认):冻结列超出最大冻结宽度时遵循原策略(可能解冻)
50+
- `true`:冻结区域内部可横向滚动,保留全部冻结列
51+
- `maxRightFrozenWidth?: number | string`
52+
- 右侧最大冻结宽度,默认与 `maxFrozenWidth` 对齐
53+
- `scrollRightFrozenCols?: boolean`
54+
- `false`(默认):右冻结区域宽度 = 内容宽度(无内部滚动)
55+
- `true`:右冻结区域内部可横向滚动
56+
57+
### 4.2 相关方法(BaseTable)
58+
59+
左冻结:
60+
61+
- `getFrozenColsContentWidth()`:冻结内容宽
62+
- `getFrozenColsWidth()`:冻结视口宽(scrollFrozenCols 开启时受 maxFrozenWidth 限制)
63+
- `getFrozenColsOffset()`:溢出量(最大可滚动距离)
64+
- `getFrozenColsScrollLeft()`:当前左冻结 scrollLeft(px)
65+
66+
右冻结:
67+
68+
- `getRightFrozenColsContentWidth()`
69+
- `getRightFrozenColsWidth()`(scrollRightFrozenCols 开启时受 maxRightFrozenWidth 限制)
70+
- `getRightFrozenColsOffset()`
71+
- `getRightFrozenColsScrollLeft()`
72+
73+
## 5. 数据结构(StateManager)
74+
75+
在 StateManager 中新增两个横向位置用于维护冻结域滚动:
76+
77+
- `scroll.frozenHorizontalBarPos`:左冻结 scrollLeft(px)
78+
- `scroll.rightFrozenHorizontalBarPos`:右冻结 scrollLeft(px)
79+
80+
并提供两类接口:
81+
82+
1) 外部“设置滚动位置”(用于 wheel / click 轨道 / 拖拽)
83+
- `setFrozenColsScrollLeft(left, triggerRender?)`
84+
- `setRightFrozenColsScrollLeft(left, triggerRender?)`
85+
86+
2) 外部“按滚动条 ratio 更新”(scrollDrag 回调给的是 range,需要映射回 scrollLeft)
87+
- `updateFrozenHorizontalScrollBar(xRatio)`
88+
- `updateRightFrozenHorizontalScrollBar(xRatio)`
89+
90+
右冻结的 ratio 与 left 做了反向映射(`ratio = 1 - left/maxScrollLeft`),以更符合“右冻结内容从右向左展开”的视觉直觉。
91+
92+
## 6. 渲染与布局(Scenegraph)
93+
94+
### 6.1 左冻结
95+
96+
左冻结的平移相对直观:冻结区域内部滚动时,对应 group 的 childrenX 直接使用 `-scrollLeft`
97+
98+
### 6.2 右冻结
99+
100+
右冻结的布局基准是“内容右对齐视口”,因此需要同时考虑溢出量 offset 与 scrollLeft:
101+
102+
- `rightFrozenStartX = -rightFrozenOffset + rightFrozenScrollLeft`
103+
104+
含义:
105+
106+
- `-offset`:使右冻结内容尾部对齐到视口右侧(把超出部分整体向左移出视口)
107+
- `+scrollLeft`:在视口内左右移动查看隐藏的列
108+
109+
对应更新点:
110+
111+
- `Scenegraph.updateContainerAttrWidthAndX()` 在布局刷新时更新 rightFrozenGroup / corner group 的 childrenX
112+
- `Scenegraph.setRightFrozenColsScrollLeft()` 在右冻结滚动变化时更新 rightFrozenGroup / rightTopCorner / rightBottomCorner 的 childrenX
113+
114+
### 6.3 Clip(裁剪)
115+
116+
多区域 overlay/内容组均依赖 clipRect 进行裁剪。右冻结视口宽度在开启 scrollRightFrozenCols 时不再等于内容宽度,需要使用 `getRightFrozenColsWidth()` 作为 clip 宽度来源,保证“内容滚动但不越界绘制”。
117+
118+
## 7. 命中与坐标映射(HitTest)
119+
120+
冻结区域内部滚动会改变“可视坐标 ↔ 内容坐标”的映射关系,因此需要在命中链路中补偿:
121+
122+
- 右冻结命中:当 x 落在右冻结视口范围内时,先将 `absoluteX -= rightFrozenScrollLeft` 再计算 target col
123+
- 右冻结列 x 计算:`getColX(col, table, true)` 叠加 `getRightFrozenColsScrollLeft()`,保证渲染坐标与 hitTest 一致
124+
125+
## 8. 事件分发(Wheel/Trackpad)
126+
127+
横向 wheel 需要判断“滚动意图属于哪个滚动域”:
128+
129+
- 优先冻结域:当指针坐标落在左冻结/右冻结视口范围内且该域可滚动(offset>0)
130+
- 否则落入 body 域
131+
132+
注意:部分环境 wheel 事件可能没有可靠的 x/y,因此引入 LastBodyPointerXY 作为回退坐标。
133+
134+
右冻结域的 delta 需要反向映射:
135+
136+
- `rightFrozenDelta = -optimizedDeltaX`
137+
138+
原因是右冻结内容的“展开方向”与 body/左冻结相反(内容从右向左展开)。
139+
140+
## 9. 滚动条系统(ScrollBar UI)
141+
142+
### 9.1 多段横向滚动条
143+
144+
当左右冻结域启用内部滚动且存在溢出时,底部会出现三段横向滚动条:
145+
146+
- body 主滚动条(hScrollBar)
147+
- 左冻结横向滚动条(frozenHScrollBar)
148+
- 右冻结横向滚动条(rightFrozenHScrollBar)
149+
150+
各段的 range(滑块长度)分别反映其域的“视口宽 / 内容宽”。
151+
152+
### 9.2 显隐策略与交互
153+
154+
`scrollStyle.visible` 在多滚动域场景下的定义:
155+
156+
- `always`:所有可滚动域的滚动条同时显示
157+
- `focus`:只显示指针所在域的滚动条(避免干扰)
158+
- `scrolling`:滚动发生时显示;hover 到滚动条区域时显示以支持交互;离开后延迟隐藏
159+
160+
实现上通过 `TableComponent.showHorizontalScrollBar(target)` 控制显示目标域,并在事件监听中根据 hover/scrolling 规则维护 autoHide。
161+
162+
## 10. 关键边界与已处理问题
163+
164+
- **右冻结分割线(shadow line)错位**:当右冻结内容可滚动时,分割线应固定在“右冻结视口左边界”而不是随内容滚动
165+
- **选区 overlay 被裁切**:当选区贴边或存在 fill handle 时,需要对 overlay 的 clipRect 进行外扩(详见选框技术设计文档)
166+
- **拖拽滚动条不生效**:throttle 绑定函数需要 bind(this),否则 this.table 不可用
167+
168+
## 11. 可观测性与测试建议
169+
170+
建议覆盖以下用例:
171+
172+
- 左冻结溢出:trackpad 横向滚动仅影响左冻结内容;body 不动
173+
- 右冻结溢出:trackpad 横向滚动方向符合预期;命中列与渲染一致
174+
- 滚动条:三段滚动条的滑块比例正确;拖拽与点击轨道能驱动对应域滚动
175+
- visible 策略:focus/scolling 下仅显示目标域滚动条且可自动隐藏
176+
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# 选框(Select Border)改造:技术设计(前/后对比)
2+
3+
本文总结 VTable 选框(selection border + fill handle)的设计背景、改造前的问题、改造后的结构与关键实现点,便于后续维护与扩展。
4+
5+
> 涉及代码:packages/vtable/src/scenegraph/select/* 及 overlay clip 相关贡献逻辑。
6+
7+
## 1. 背景
8+
9+
VTable 的“选框”由两个主要视觉元素组成:
10+
11+
- **selection border**:选区外边框(可配置边框色、线宽、虚线等)
12+
- **fill handle**:右下角 6x6 小方块(Excel 类填充柄),受 `excelOptions.fillHandle` 控制
13+
14+
在引入“冻结区域独立滚动”后,表格的可视区域被拆成多个区域(body/headers/leftFrozen/rightFrozen/bottomFrozen),选框需要:
15+
16+
- 正确出现在对应区域的 overlay 层并被 clip 裁剪
17+
- 支持跨区域选择(需要拆分多段绘制)
18+
- 避免在跨区域边界重复描边(导致边框变粗)
19+
- 在贴边/裁剪边界时不出现“半条线”或 fill handle 被切掉
20+
21+
## 2. 改造前设计(概念层面)
22+
23+
### 2.1 选框的基本组织方式
24+
25+
- 选框图元挂在 overlayGroup 下(select-overlay),overlayGroup 会被各区域 clipRect 裁剪。
26+
- 对于跨区域选区,会拆分为多个选框段落(每段一个 rect,必要时一个 fill handle)。
27+
28+
### 2.2 主要痛点
29+
30+
1) **贴边裁剪导致的边框缺失**
31+
- 选框的描边线宽是以 rect 边界为中心绘制;当选区贴着表格边缘或区域 clip 边界时,一半线宽会被裁剪,呈现“半条线”。
32+
33+
2) **fill handle 被裁剪或定位异常**
34+
- 在最后一行/最后一列等边界场景,fill handle 可能落到 clip 外,导致不可见或难以命中。
35+
36+
3) **跨区域选框的重复描边**
37+
- 选区跨越多个 overlay(例如 columnHeader + body,或 body + rightFrozen)时,如果各段都绘制相邻边,会在边界处叠加出更粗的边框。
38+
39+
4) **场景树重建导致选区丢失**
40+
- 数据更新导致 scenegraph 重建时,overlay 下的选区图元会被清空,需要从 state 恢复。
41+
42+
## 3. 改造后设计(实现层面)
43+
44+
改造后的原则:
45+
46+
- **跨区域必拆分**:按区域拆分为多段选框,每段只负责自己的绘制与裁剪
47+
- **边界不重复描边**:通过 strokeArray 控制每段四边是否绘制
48+
- **贴边不裁半线**:在选框更新时对 rect 做“半线宽补偿”,同时对 overlay clipRect 做“外扩”
49+
- **fill handle 只在可解释场景出现**:单选区 + 非表头 + 边不被禁用时显示
50+
51+
## 4. 关键实现点
52+
53+
### 4.1 选框创建:overlay 坐标换算与 fill handle 显示条件
54+
55+
文件:
56+
57+
- packages/vtable/src/scenegraph/select/create-select-border.ts
58+
59+
要点:
60+
61+
- 使用 `highPerformanceGetCell(...).globalAABBBounds` 获取单元格全局边界,再减去 `tableGroup + overlayGroup` 的偏移,换算成 overlay 本地坐标。
62+
- fill handle 的显示需要满足:
63+
- `excelOptions.fillHandle` 开启
64+
- 当前仅 1 个选区(多选区时移除所有 handle)
65+
- 选区不包含表头(header 不允许填充)
66+
- strokes 中右边或下边被关闭时不显示(避免 handle 由其它段负责时重复出现)
67+
68+
### 4.2 选框更新:可视范围裁剪与 fill handle 边界定位
69+
70+
文件:
71+
72+
- packages/vtable/src/scenegraph/select/update-select-border.ts
73+
74+
要点:
75+
76+
1) **按 role 裁剪计算范围**
77+
78+
不同区域的选框更新策略不同:
79+
80+
- `rowHeader`:只裁剪行范围(跟随 body 可视行)
81+
- `columnHeader` / `bottomFrozen`:只裁剪列范围(跟随 body 可视列)
82+
- `rightFrozen`:只裁剪行范围(跟随 body 可视行)
83+
- `body`:裁剪行列范围
84+
85+
目的是避免更新不可见区域的选框段落,降低滚动过程的更新开销。
86+
87+
2) **fill handle 边界推导**
88+
89+
当选区触达最后一列/最后一行时,直接取 “end cell bound” 可能导致 handle 超出 clip。实现上通过相邻单元格的 bound 推导 `handlerX/handlerY`,让 handle 保持在可见边界附近。
90+
91+
3) **贴边半线宽裁切修正**
92+
93+
当选区贴着表格外边界时,通过根据 lineWidth 计算 diffSize,对 rect 做 x/y/width/height 的微调,避免“半条线”现象。
94+
95+
### 4.3 跨区域拆分:calculateCellRangeDistribution + strokeArray
96+
97+
文件:
98+
99+
- packages/vtable/src/scenegraph/select/update-select-border.ts
100+
- packages/vtable/src/scenegraph/select/update-custom-select-border.ts
101+
102+
流程:
103+
104+
1) `calculateCellRangeDistribution(startCol, startRow, endCol, endRow, table)` 判断选区跨越哪些区域。
105+
2) 针对每个需要的区域创建一段选框,传入该段负责的范围。
106+
3) 通过 `strokeArray=[top,right,bottom,left]` 控制该段四边是否绘制,避免跨区域边界重复描边。
107+
108+
自定义选框(CustomSelectionStyle)沿用相同的拆分策略,但只绘制 rect,不包含 fill handle。
109+
110+
### 4.4 selecting → selected 的提交语义
111+
112+
文件:
113+
114+
- packages/vtable/src/scenegraph/select/move-select-border.ts
115+
116+
语义:
117+
118+
- `selectingRangeComponents` 表示“拖拽中”的临时选框
119+
- 鼠标松开后需要迁移到 `selectedRangeComponents`,作为稳定的选中态
120+
- 若同 key 已存在历史段落,先 delete 避免泄漏与重复绘制
121+
122+
### 4.5 删除逻辑:shift 续选与 fill handle 清理
123+
124+
文件:
125+
126+
- packages/vtable/src/scenegraph/select/delete-select-border.ts
127+
128+
要点:
129+
130+
- 通过 `scene.lastSelectId` 识别“上一次选择动作”产生的所有段落(跨区域拆分时 selectId 相同)
131+
- shift 续选需要删除上一次选择段落,再追加新的段落
132+
- 多选区时需要统一移除 fill handle
133+
134+
### 4.6 overlay 裁剪外扩:避免边框与 fill handle 被 clip 截断
135+
136+
文件:
137+
138+
- packages/vtable/src/scenegraph/graphic/contributions/group-contribution-render.ts
139+
140+
要点:
141+
142+
- overlay(select-overlay)组会被各区域 clipRect 裁剪
143+
- 对 overlay 的 clipRect 进行“外扩”(inflate):
144+
- baseInflate:覆盖 selection border 的线宽
145+
- handleInflate:当开启 fill handle 且只有一个选区时,为 6x6 handle 预留空间(3px)
146+
147+
这一层和 4.2 的贴边修正配合,解决“线宽/handle 被裁切”的可见性问题。
148+
149+
### 4.7 场景树重建后的选区恢复
150+
151+
文件:
152+
153+
- packages/vtable/src/scenegraph/scenegraph.ts
154+
155+
要点:
156+
157+
- 数据更新触发 scenegraph 重建会清空 overlay 下的选区图元
158+
- 若 state 中仍存在 select ranges,需要在场景树重建完成后重新创建选区组件,保证选中态不丢失
159+
160+
## 5. 行为对比(摘要)
161+
162+
- 改造前:选区贴边时边框/handle 可能被裁,跨区域容易重复描边,scenegraph 重建后可能丢失选区图元。
163+
- 改造后:通过“跨区域拆分 + strokeArray 去重 + overlay clip 外扩 + 贴边半线宽修正 + 重建后恢复”保证一致性与可维护性。
164+
165+
## 6. 测试建议
166+
167+
- 单选区 + fill handle:拖拽到最后一行/最后一列仍可见且可命中
168+
- 多选区:fill handle 不出现,且历史 handle 会被清理
169+
- 跨区域选择:跨表头/左冻结/右冻结/底部冻结时边框不加粗
170+
- scrollFrozenCols/scrollRightFrozenCols 开启:滚动冻结域时选框与 handle 不被裁切、不漂移
171+

packages/vtable/src/core/BaseTable.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3014,21 +3014,26 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
30143014
*/
30153015
getFrozenColsWidth(): number {
30163016
const contentWidth = this.getFrozenColsContentWidth();
3017+
// frozenColsWidth 表示“冻结区域视口宽度”,可能小于冻结列内容总宽。
3018+
// 当开启 scrollFrozenCols 时,冻结区域会限制到 maxFrozenWidth,并允许在冻结区域内部横向滚动来查看超出部分。
30173019
if (!this.options.scrollFrozenCols) {
30183020
return contentWidth;
30193021
}
30203022
const maxFrozenWidth = this._getMaxFrozenWidth();
30213023
return Math.min(contentWidth, maxFrozenWidth);
30223024
}
30233025
getFrozenColsContentWidth(): number {
3026+
// 冻结列内容总宽(不受 maxFrozenWidth 限制)
30243027
return this.getColsWidth(0, this.frozenColCount - 1);
30253028
}
30263029
getFrozenColsOffset(): number {
3030+
// 冻结区域可滚动的最大距离(内容宽 - 视口宽),用于计算滚动条范围与边界判断
30273031
const contentWidth = this.getFrozenColsContentWidth();
30283032
const viewportWidth = this.getFrozenColsWidth();
30293033
return Math.max(0, contentWidth - viewportWidth);
30303034
}
30313035
getFrozenColsScrollLeft(): number {
3036+
// 左冻结区域内部的横向滚动位置(像素值)
30323037
return this.stateManager.scroll.frozenHorizontalBarPos ?? 0;
30333038
}
30343039
/**
@@ -3052,6 +3057,8 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
30523057
*/
30533058
getRightFrozenColsWidth(): number {
30543059
const contentWidth = this.getRightFrozenColsContentWidth();
3060+
// rightFrozenColsWidth 表示“右侧冻结区域视口宽度”。
3061+
// 当开启 scrollRightFrozenCols 时,右侧冻结区域会限制到 maxRightFrozenWidth,并允许在右冻结区域内部横向滚动。
30553062
if (!this.options.scrollRightFrozenCols) {
30563063
return contentWidth;
30573064
}
@@ -3069,11 +3076,13 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
30693076
return 0;
30703077
}
30713078
getRightFrozenColsOffset(): number {
3079+
// 右侧冻结区域可滚动的最大距离(内容宽 - 视口宽)
30723080
const contentWidth = this.getRightFrozenColsContentWidth();
30733081
const viewportWidth = this.getRightFrozenColsWidth();
30743082
return Math.max(0, contentWidth - viewportWidth);
30753083
}
30763084
getRightFrozenColsScrollLeft(): number {
3085+
// 右冻结区域内部的横向滚动位置(像素值)
30773086
return this.stateManager.scroll.rightFrozenHorizontalBarPos ?? 0;
30783087
}
30793088
/**

0 commit comments

Comments
 (0)