@hzab/edit-table 0.1.13-alpha.0 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,8 @@ export interface BaseCell extends Cell {
28
28
  value: string | number;
29
29
  values: OptionType[];
30
30
  isDisabled?: boolean;
31
+ /** 运行时附加(index.tsx 构造单元格时写入),reactgrid 的 Cell/Compatible 类型不含此字段 */
32
+ rowId?: string | number;
31
33
  }
32
34
  interface DIProps {
33
35
  onCellChanged: (...args: any[]) => void;
@@ -71,6 +73,11 @@ export const WidthWrapHoc = (WrapComponent: any, props = {}) => {
71
73
  }
72
74
 
73
75
  getClassName(cell: Compatible<BaseCell>, isInEditMode: boolean): string {
76
+ // 整行高亮:选中行由全局变量标记,给 .rg-cell 追加高亮 class,使背景切换为高亮色
77
+ const highlightRowId = (window as any).gridHighlightRowId;
78
+ if (highlightRowId && cell.rowId === highlightRowId) {
79
+ return "custom-highlights-row";
80
+ }
74
81
  return "";
75
82
  }
76
83
  /** enableEditMode为true 时输入中文会报错 */
@@ -104,12 +111,31 @@ export const WidthWrapHoc = (WrapComponent: any, props = {}) => {
104
111
 
105
112
  /**
106
113
  * 通用单元格内容高度上报:内容换行/增减时自动撑开所在行(表头行、角标跳过)。
107
- * 测量对象是"内容子元素"而非容器自身 —— 容器带 min-height:100%(撑起双击热区),
108
- * 其 scrollHeight 存在"下限=当前行高"的假象,内容变矮时测不出真实高度;
109
- * 内容子元素无此限制,双向(撑开/回落)测量都真实,行高一次到位、无渐变动画。
114
+ * 测量对象是"内容子树的真实占用高度"而非容器 scrollHeight —— 容器带 min-height:100%
115
+ * (撑起双击热区)或子容器残留 inline 高度时(如上传组件编辑态的 antd 动画容器),
116
+ * scrollHeight 会取"容器自身高度"与"内容高度"的较大者,把空白也测进去;
117
+ * 改为累加可见子元素的 margin-box 占据区间(首个子元素 margin 上缘 → 末个 margin 下缘),
118
+ * 只反映真实内容,双向(撑开/回落)测量都真实,行高一次到位、无渐变动画。
110
119
  */
120
+ const measureContentHeight = (container: Element): number => {
121
+ const els = Array.from(container.children) as HTMLElement[];
122
+ if (els.length === 0) return (container as HTMLElement).clientHeight;
123
+ let top = Infinity;
124
+ let bottom = -Infinity;
125
+ for (const c of els) {
126
+ const r = c.getBoundingClientRect();
127
+ const cs = getComputedStyle(c);
128
+ const mt = parseFloat(cs.marginTop) || 0;
129
+ const mb = parseFloat(cs.marginBottom) || 0;
130
+ top = Math.min(top, r.top - mt);
131
+ bottom = Math.max(bottom, r.bottom + mb);
132
+ }
133
+ return Math.max(0, Math.ceil(bottom - top));
134
+ };
135
+
111
136
  useEffect(() => {
112
137
  const el = wrapRef.current;
138
+
113
139
  const onRowHeightChange = cell?.onRowHeightChange;
114
140
  if (!el || !onRowHeightChange) return;
115
141
  if (cell?.rowId == null) return;
@@ -122,13 +148,16 @@ export const WidthWrapHoc = (WrapComponent: any, props = {}) => {
122
148
  paddingCacheRef.current =
123
149
  (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
124
150
  }
125
- // 测子元素时需补回容器垂直 padding;退化测容器自身时 scrollHeight 已含 padding
151
+ // 测子元素时需补回容器垂直 padding;退化测容器自身时 clientHeight 已含 padding
126
152
  const extra = child ? paddingCacheRef.current : 0;
127
- const report = () =>
128
- onRowHeightChange(cell?.rowId, target.scrollHeight + extra, cell?.columnId);
129
- report();
130
- const ro = new ResizeObserver(report);
153
+ const ro = new ResizeObserver(() => {
154
+ // 内容子元素可能新增(如上传列表新增条目),补充观察,确保其尺寸变化也能触发重测
155
+ Array.from(target.children).forEach((c) => ro.observe(c));
156
+ const h = measureContentHeight(target) + extra;
157
+ onRowHeightChange(cell?.rowId, h, cell?.columnId);
158
+ });
131
159
  ro.observe(target);
160
+ Array.from(target.children).forEach((c) => ro.observe(c));
132
161
  return () => ro.disconnect();
133
162
  }, [cell?.rowId, cell?.columnId, cell?.onRowHeightChange, edit, comValue]);
134
163
  useEffect(() => {
@@ -0,0 +1,239 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export const useDynamicHeight = (props) => {
4
+ const { wrapRef, rowHeight, setRows } = props
5
+ /** 高度上报按帧合并缓冲,一帧内多次上报只触发一次 state 更新 */
6
+ const pendingHeightsRef = useRef<any>({});
7
+ const flushHeightsRafRef = useRef<any>();
8
+ /** 用户滚动位置记忆:持续跟踪,供编辑/重建后恢复 */
9
+ const scrollMemoryRef = useRef({ top: 0, left: 0 });
10
+ /** 滚动重置保护窗口截止时间戳:窗口内"回到顶部/最左"的程序化重置被纠正回记忆位置 */
11
+ const scrollProtectUntilRef = useRef(0);
12
+ /** rows 的镜像 ref:updater 执行时机不确定,编辑等纯计算需读取即时值 */
13
+ const rowsRef = useRef<any>([]);
14
+ /** 附件等动态行高:rowId -> { [columnId]: height },同行多附件列时取最大值 */
15
+ const [rowHeights, setRowHeights] = useState<any>({});
16
+ /** 编辑提交时开启的长保护窗(ms),覆盖 onEditSubmit → getList 全量刷新 → rows 重建的完整链路 */
17
+ const SCROLL_RESET_GUARD_MS = 3000;
18
+
19
+ const armScrollProtect = useCallback((duration = 500) => {
20
+ const el = wrapRef.current;
21
+ if (!el) return;
22
+ scrollMemoryRef.current = { top: el.scrollTop, left: el.scrollLeft };
23
+ // 只延长不缩短:编辑链路开的 3000ms 长窗不能被 rows 重建 effect 的 500ms 短窗覆盖
24
+ scrollProtectUntilRef.current = Math.max(
25
+ scrollProtectUntilRef.current || 0,
26
+ Date.now() + duration,
27
+ );
28
+ }, []);
29
+
30
+ const restoreScroll = useCallback(() => {
31
+ const el = wrapRef.current;
32
+ if (!el) return;
33
+ const { top, left } = scrollMemoryRef.current;
34
+ // 双层 rAF:等 ReactGrid componentDidUpdate 的聚焦滚动写入先执行,再恢复用户位置
35
+ requestAnimationFrame(() => {
36
+ requestAnimationFrame(() => {
37
+ if (el.scrollTop !== top) el.scrollTop = top;
38
+ if (el.scrollLeft !== left) el.scrollLeft = left;
39
+ });
40
+ });
41
+ }, []);
42
+ /**
43
+ * reactgrid 每次 pointerdown(点击单元格)都会对隐藏的键盘捕获 input(.rg-hidden-element)
44
+ * 调用 focus();该元素 position:fixed,静态位置落在滚动内容末尾(视口下边缘之外),
45
+ * 浏览器原生 scroll-into-view 会把表格滚动容器往下带一点(数据多到出现滚动条时可见)。
46
+ * 此处统一改为 preventScroll,焦点行为不变,仅禁止滚动。
47
+ */
48
+ useEffect(() => {
49
+ const root = wrapRef.current;
50
+ if (!root) return;
51
+ const patchHiddenFocus = () => {
52
+ root.querySelectorAll(".rg-hidden-element").forEach((el: any) => {
53
+ if (el.__focusScrollPatched) return;
54
+ el.__focusScrollPatched = true;
55
+ const rawFocus = el.focus.bind(el);
56
+ el.focus = (options?: FocusOptions) => rawFocus({ preventScroll: true, ...(options || {}) });
57
+ });
58
+ };
59
+ patchHiddenFocus();
60
+ const mo = new MutationObserver(patchHiddenFocus);
61
+ mo.observe(root, { childList: true, subtree: true });
62
+ return () => mo.disconnect();
63
+ }, []);
64
+
65
+ /**
66
+ * 滚动守卫:点击单元格时 reactgrid 会在 componentDidUpdate 里程序化写 scrollTop
67
+ * 做"聚焦行跟随"(scrollIntoView)。动态行高在编辑态切换时被反复重新测量、行高收敛
68
+ * 过程中每次状态更新都会触发一次 1~2px 的修正性滚动(focus-scroll-into-view 的效果)。
69
+ * 用户滚轮/触摸滚动是原生行为、不经过 JS scrollTop 赋值,因此这里拦截元素上的
70
+ * scrollTop/scrollTo/scrollBy 写入只影响程序化滚动:忽略 ≤3px 的微量修正,
71
+ * 大步长滚动(方向键导航等)不受影响。
72
+ */
73
+ useEffect(() => {
74
+ const el: any = wrapRef.current;
75
+ if (!el || el.__scrollGuard) return;
76
+ el.__scrollGuard = true;
77
+ const desc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop") as PropertyDescriptor;
78
+ const MAX_CORRECTION = 3;
79
+
80
+ Object.defineProperty(el, "scrollTop", {
81
+ configurable: true,
82
+ get() {
83
+ return desc.get!.call(el);
84
+ },
85
+ set(v: any) {
86
+ const next = Number(v) || 0;
87
+ const cur = desc.get!.call(el);
88
+ const delta = next - cur;
89
+ if (delta !== 0 && Math.abs(delta) <= MAX_CORRECTION) {
90
+ return;
91
+ }
92
+ if (next === 0 && cur > 50) {
93
+ console.warn("[CellEditTable] 检测到程序化滚动回顶部: ", cur, "->", next, new Error().stack);
94
+ }
95
+ desc.set!.call(el, next);
96
+ },
97
+ });
98
+
99
+ const rawScrollTo = el.scrollTo.bind(el);
100
+ el.scrollTo = (x: any, y?: any) => {
101
+ if (typeof x === "object" && x !== null) {
102
+ if (x.top != null) {
103
+ const cur = desc.get!.call(el);
104
+ const delta = Number(x.top) - cur;
105
+ if (delta !== 0 && Math.abs(delta) <= MAX_CORRECTION) {
106
+ return;
107
+ }
108
+ }
109
+ return rawScrollTo(x);
110
+ }
111
+ if (typeof x === "number" && typeof y === "number") {
112
+ const cur = desc.get!.call(el);
113
+ const delta = y - cur;
114
+ if (delta !== 0 && Math.abs(delta) <= MAX_CORRECTION) {
115
+ return;
116
+ }
117
+ }
118
+ return rawScrollTo(x, y);
119
+ };
120
+
121
+ const rawScrollBy = el.scrollBy.bind(el);
122
+ el.scrollBy = (x: any, y?: any) => {
123
+ if (typeof x === "object" && x !== null) {
124
+ if (x.top != null && Math.abs(Number(x.top)) <= MAX_CORRECTION) {
125
+ return;
126
+ }
127
+ return rawScrollBy(x);
128
+ }
129
+ if (typeof x === "number" && typeof y === "number" && Math.abs(y) <= MAX_CORRECTION) {
130
+ return;
131
+ }
132
+ return rawScrollBy(x, y);
133
+ };
134
+ }, []);
135
+ /**
136
+ * 滚动位置记忆与重置纠正:持续跟踪用户滚动位置;
137
+ * 保护窗口内若出现"回到顶部/最左"式跳变(scrollTop 归 0 且记忆位置较深),
138
+ * 视为 ReactGrid/浏览器的程序化重置,立即纠正回记忆位置。
139
+ * 只拦截归零式重置,不影响窗口内的正常滚动与键盘导航滚动。
140
+ */
141
+ useEffect(() => {
142
+ const el: any = wrapRef.current;
143
+ if (!el) return;
144
+ const onScroll = () => {
145
+ if (
146
+ Date.now() < scrollProtectUntilRef.current &&
147
+ ((el.scrollTop === 0 && scrollMemoryRef.current.top > 50) ||
148
+ (el.scrollLeft === 0 && scrollMemoryRef.current.left > 50))
149
+ ) {
150
+ el.scrollTop = scrollMemoryRef.current.top;
151
+ el.scrollLeft = scrollMemoryRef.current.left;
152
+ return;
153
+ }
154
+ scrollMemoryRef.current = { top: el.scrollTop, left: el.scrollLeft };
155
+ };
156
+ el.addEventListener("scroll", onScroll, true);
157
+ return () => el.removeEventListener("scroll", onScroll, true);
158
+ }, []);
159
+ const flushPendingHeights = useCallback(() => {
160
+ flushHeightsRafRef.current = undefined;
161
+ const pending = pendingHeightsRef.current;
162
+ if (Object.keys(pending).length === 0) return;
163
+ pendingHeightsRef.current = {};
164
+ setRowHeights((prev: any) => {
165
+ let next = prev;
166
+ for (const rowId in pending) {
167
+ const cols = { ...(next[rowId] || {}) };
168
+ let rowChanged = false;
169
+ for (const colId in pending[rowId]) {
170
+ const h = pending[rowId][colId];
171
+ if (cols[colId] !== h) {
172
+ cols[colId] = h;
173
+ rowChanged = true;
174
+ }
175
+ }
176
+ if (rowChanged) {
177
+ if (next === prev) next = { ...prev };
178
+ next[rowId] = cols;
179
+ }
180
+ }
181
+ return next === prev ? prev : next;
182
+ });
183
+ }, []);
184
+
185
+ /** 供单元格(如 Upload 附件)回传内容高度,撑开/恢复所在行高 */
186
+ const handleRowHeightChange = useCallback(
187
+ (rowId: any, height?: number, columnId?: string) => {
188
+ const h = Math.max(Number(height) || 0, rowHeight);
189
+ const rId = String(rowId);
190
+ const cId = columnId || "_";
191
+ const row = (pendingHeightsRef.current[rId] = pendingHeightsRef.current[rId] || {});
192
+ if (row[cId] === h) return;
193
+ row[cId] = h;
194
+ if (!flushHeightsRafRef.current) {
195
+ flushHeightsRafRef.current = requestAnimationFrame(flushPendingHeights);
196
+ }
197
+ },
198
+ [rowHeight, flushPendingHeights],
199
+ );
200
+
201
+ /** 组件卸载时取消未执行的合帧刷新 */
202
+ useEffect(() => {
203
+ return () => {
204
+ if (flushHeightsRafRef.current) cancelAnimationFrame(flushHeightsRafRef.current);
205
+ };
206
+ }, []);
207
+
208
+ /** 行高变化时仅补丁式更新受影响行的 height,避免整表重建 */
209
+ useEffect(() => {
210
+ setRows((prev: any) => {
211
+ if (!Array.isArray(prev) || prev.length === 0) return prev;
212
+ let changed = false;
213
+ const next = prev.map((row) => {
214
+ // 表头行高度固定,不参与动态行高
215
+ if (typeof row?.rowId === "string" && row.rowId.indexOf("header-") === 0) return row;
216
+ const heightMap = rowHeights[row.rowId];
217
+ const h =
218
+ heightMap && Object.keys(heightMap).length > 0
219
+ ? Math.max(rowHeight, ...Object.values(heightMap).map(Number))
220
+ : rowHeight;
221
+ if (row.height === h) return row;
222
+ changed = true;
223
+ return { ...row, height: h };
224
+ });
225
+ const result = changed ? next : prev;
226
+ rowsRef.current = result;
227
+ return result;
228
+ });
229
+ }, [rowHeights, rowHeight]);
230
+
231
+ return {
232
+ handleRowHeightChange,
233
+ armScrollProtect,
234
+ restoreScroll,
235
+ SCROLL_RESET_GUARD_MS,
236
+ rowsRef,
237
+ rowHeights
238
+ }
239
+ }
package/src/index.less CHANGED
@@ -35,6 +35,9 @@
35
35
 
36
36
  .rg-cell {
37
37
  padding: 0px !important;
38
+ /** hidden 兜底,clip 不建立滚动容器:单元格内部 focus 时浏览器不会再把它内部滚动 */
39
+ overflow: hidden;
40
+ overflow: clip;
38
41
  }
39
42
 
40
43
  .rg-cell-focus {
@@ -57,13 +60,42 @@
57
60
  align-items: center;
58
61
  justify-content: center;
59
62
  z-index: 1;
60
- padding: 4px;
63
+ }
64
+ .rg-cell > .base-hoc {
65
+ /** 单元格内边距(回显态与编辑态一致;行高测量会按此 padding 补偿,见 WidthWrapHoc) */
66
+ padding: 8px;
61
67
  }
62
68
 
63
69
  .base-hoc-edit {
64
70
  background-color: #fff;
65
71
  }
66
72
 
73
+ /** 可编辑单元格(非 nonEditable)默认底色,用于区分不可编辑单元格;
74
+ 设置在 .rg-cell 层级,内层 .base-hoc 透明透出,避免嵌套 .base-hoc(如 cellRender 返回的“未填写”)盖住底色 */
75
+ .rg-cell:not(.rg-cell-nonEditable) {
76
+ background-color: #f9f9ff;
77
+ }
78
+ .rg-cell.rg-rowSelection-cell{
79
+ background-color: #ffffff;
80
+ }
81
+
82
+
83
+
84
+
85
+ /** 整行点击高亮:.rg-cell 背景统一切换为高亮色 #e9f8ff,覆盖包括 #f9f9ff 在内的所有其他背景。
86
+ .custom-highlights-row 由 JS(WidthWrapHoc.getClassName)追加到选中行的 .rg-cell 上 */
87
+ .rg-cell.custom-highlights-row {
88
+ background-color: #e9f8ff;
89
+ }
90
+
91
+ /** 高亮行内编辑态单元格(.base-hoc-edit 默认 #fff)也覆盖为高亮色,保证整行背景一致 */
92
+ .rg-cell.custom-highlights-row .base-hoc-edit {
93
+ background-color: #ffffff;
94
+ }
95
+
96
+
97
+
98
+
67
99
  /** 组件回显/编辑内容容器:纯内容高度,不设 min-height(保证高度测量双向真实) */
68
100
  .base-hoc-inner {
69
101
  width: 100%;