react-extjs 0.0.1

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +36 -0
  3. package/src/components/AbsoluteLayout.tsx +206 -0
  4. package/src/components/Accordion.tsx +266 -0
  5. package/src/components/AnchorLayout.tsx +131 -0
  6. package/src/components/BorderLayout.tsx +512 -0
  7. package/src/components/Box.tsx +289 -0
  8. package/src/components/Button.tsx +300 -0
  9. package/src/components/ButtonGroup.tsx +170 -0
  10. package/src/components/CalendarPicker.tsx +431 -0
  11. package/src/components/CardLayout.tsx +122 -0
  12. package/src/components/CenterLayout.tsx +95 -0
  13. package/src/components/CheckboxGroup.tsx +200 -0
  14. package/src/components/ColorMenu.tsx +195 -0
  15. package/src/components/ColorPalette.tsx +126 -0
  16. package/src/components/ColumnLayout.tsx +183 -0
  17. package/src/components/ColumnTree.tsx +383 -0
  18. package/src/components/CycleButton.tsx +302 -0
  19. package/src/components/DataView.tsx +244 -0
  20. package/src/components/DataViewMore.tsx +337 -0
  21. package/src/components/DataViewTransition.tsx +235 -0
  22. package/src/components/DateMenu.tsx +163 -0
  23. package/src/components/DatePicker.tsx +387 -0
  24. package/src/components/DisplayField.tsx +73 -0
  25. package/src/components/Editor.tsx +214 -0
  26. package/src/components/EditorGrid.tsx +361 -0
  27. package/src/components/FieldLabeler.tsx +81 -0
  28. package/src/components/FieldReplicator.tsx +135 -0
  29. package/src/components/FieldSet.tsx +149 -0
  30. package/src/components/FitLayout.tsx +76 -0
  31. package/src/components/Form.tsx +3484 -0
  32. package/src/components/Grid.tsx +2173 -0
  33. package/src/components/GridFilters.tsx +332 -0
  34. package/src/components/GroupTab.tsx +274 -0
  35. package/src/components/HtmlEditor.tsx +391 -0
  36. package/src/components/ImageOrganizer.tsx +984 -0
  37. package/src/components/ImageOrganizerService.ts +361 -0
  38. package/src/components/ListView.tsx +221 -0
  39. package/src/components/LoadMask.tsx +74 -0
  40. package/src/components/Menu.tsx +227 -0
  41. package/src/components/MessageBox.tsx +138 -0
  42. package/src/components/MultiSlider.tsx +369 -0
  43. package/src/components/Organizer.tsx +403 -0
  44. package/src/components/PagingToolbar.tsx +255 -0
  45. package/src/components/Panel.tsx +451 -0
  46. package/src/components/ProgressBar.tsx +127 -0
  47. package/src/components/PropertyGrid.tsx +142 -0
  48. package/src/components/QuickTip.tsx +174 -0
  49. package/src/components/Resizable.tsx +241 -0
  50. package/src/components/RowLayout.tsx +126 -0
  51. package/src/components/Slider.tsx +353 -0
  52. package/src/components/SliderField.tsx +206 -0
  53. package/src/components/SpinnerField.tsx +141 -0
  54. package/src/components/SplitBar.tsx +215 -0
  55. package/src/components/Spotlight.tsx +262 -0
  56. package/src/components/StatusBar.tsx +118 -0
  57. package/src/components/TabPanel.tsx +540 -0
  58. package/src/components/TableLayout.tsx +132 -0
  59. package/src/components/ThemeSwitcher.tsx +167 -0
  60. package/src/components/ToolTip.tsx +226 -0
  61. package/src/components/Toolbar.tsx +321 -0
  62. package/src/components/Tree.tsx +809 -0
  63. package/src/components/TreeGrid.tsx +418 -0
  64. package/src/components/Viewport.tsx +218 -0
  65. package/src/components/Window.tsx +441 -0
  66. package/src/components/Writer.tsx +303 -0
  67. package/src/components/XmlTreeLoader.tsx +233 -0
  68. package/src/components/dnd/DndContext.tsx +328 -0
  69. package/src/components/dnd/DragSource.tsx +202 -0
  70. package/src/components/dnd/DragZone.tsx +66 -0
  71. package/src/components/dnd/DropTarget.tsx +86 -0
  72. package/src/components/dnd/DropZone.tsx +166 -0
  73. package/src/components/dnd/PanelProxy.tsx +81 -0
  74. package/src/components/dnd/PanelResizer.tsx +143 -0
  75. package/src/components/dnd/Portal.tsx +254 -0
  76. package/src/components/dnd/Reorderer.tsx +73 -0
  77. package/src/components/dnd/ToolbarReorderer.tsx +226 -0
  78. package/src/main.tsx +2649 -0
  79. package/src/styles/ext-theme.css +889 -0
  80. package/src/util/theme.ts +95 -0
@@ -0,0 +1,337 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
+
3
+ /**
4
+ * DataViewMore —— 保真复刻 Ext.ux.DataView-more(examples/ux/DataView-more.js)。
5
+ *
6
+ * 该 UX 文件提供两个 DataView 增强(plugin):
7
+ * 1. Ext.DataView.LabelEditor —— 内联标签编辑器:点击项内的 span.x-editable 即浮出
8
+ * Ext.Editor(TextField)覆盖在原标签上(hideEl:false),回车/Esc/失焦完成编辑、写回
9
+ * record 数据(onSave -> record.set(dataIndex, value))。对齐项:
10
+ * - alignment:"tl-tl"、hideEl:false、shim:false、completeOnEnter、cancelOnEsc、
11
+ * labelSelector:"span.x-editable"、selectOnFocus。
12
+ * 2. Ext.DataView.DragSelector —— 橡皮筋框选:在容器空白处按下拖拽绘制 x-view-selector,
13
+ * 松手时选中与框相交的全部项(等价 Ext.dd.DragTracker 的 onDrag 命中测试)。
14
+ *
15
+ * 本项目 base DataView 已内联实现 DragSelector 行为(x-view-selector 橡皮筋 + 相交选中),
16
+ * 故此处 DataViewMore 直接复用同款框选逻辑,并新增 LabelEditor 这一真正缺失的能力。
17
+ *
18
+ * 保真契约(DOM/class):
19
+ * - 容器 div.x-data-view(position:relative,承载 x-view-selector 框)。
20
+ * - 每个条目 div.x-view-item(x-list-selected 选中 / x-view-over 悬停标记,与 base DataView 一致;
21
+ * 选中态复用 Ext.ListView 的 x-list-selected,xtheme 蓝 #dfe8f6 / 灰 #f0f0f0 原生主题化)。
22
+ * - 条目内可编辑标题用 span.x-editable(labelSelector),编辑时浮出
23
+ * input.x-form-text.x-small-editor(cls 对齐 Ext.Editor)。
24
+ * - 框选浮层 div.x-view-selector(#c3daf9 背景 + #3399bb 边框,ext-all.css 既有规则)。
25
+ */
26
+
27
+ /* ---------------- 内联标签编辑器(Ext.DataView.LabelEditor 复刻) ---------------- */
28
+
29
+ export interface DataViewLabelEditorProps {
30
+ /** 当前标签文本(等价 record.data[dataIndex]) */
31
+ value: string;
32
+ /** 编辑完成(回车 / 失焦)时写回,等价 onSave -> record.set(dataIndex, value) */
33
+ onCommit: (value: string) => void;
34
+ /** 等同 Ext.Editor 的 dataIndex(仅作语义标注,便于与 store 字段对应) */
35
+ dataIndex?: string;
36
+ className?: string;
37
+ }
38
+
39
+ /**
40
+ * 内联可编辑标签:静止时渲染 span.x-editable,点击(非 Ctrl/Shift)在其上覆盖一个输入框,
41
+ * 完成即写回。对齐 Ext.DataView.LabelEditor 的 mousedown + startEdit + onSave 链路。
42
+ */
43
+ export const DataViewLabelEditor: React.FC<DataViewLabelEditorProps> = ({
44
+ value,
45
+ onCommit,
46
+ className,
47
+ }) => {
48
+ const [editing, setEditing] = useState(false);
49
+ const [draft, setDraft] = useState(value);
50
+ const inputRef = useRef<HTMLInputElement>(null);
51
+
52
+ const startEdit = useCallback(
53
+ (e: React.MouseEvent) => {
54
+ // 仅左键、无修饰键时进入编辑(对齐 onMouseDown 的 !ctrlKey && !shiftKey 守卫)
55
+ if (e.button !== 0 || e.ctrlKey || e.shiftKey) return;
56
+ e.stopPropagation();
57
+ e.preventDefault();
58
+ setDraft(value);
59
+ setEditing(true);
60
+ // 聚焦并全选(selectOnFocus:true)
61
+ requestAnimationFrame(() => {
62
+ const el = inputRef.current;
63
+ if (el) {
64
+ el.focus();
65
+ el.select();
66
+ }
67
+ });
68
+ },
69
+ [value],
70
+ );
71
+
72
+ const commit = useCallback(() => {
73
+ setEditing(false);
74
+ if (draft !== value) onCommit(draft);
75
+ }, [draft, value, onCommit]);
76
+
77
+ const cancel = useCallback(() => {
78
+ setEditing(false);
79
+ setDraft(value);
80
+ }, [value]);
81
+
82
+ return (
83
+ <span
84
+ className={['x-editable', className].filter(Boolean).join(' ')}
85
+ style={{ position: 'relative', display: 'inline-block' }}
86
+ onMouseDown={startEdit}
87
+ >
88
+ {value}
89
+ {editing && (
90
+ <input
91
+ ref={inputRef}
92
+ className="x-form-text x-small-editor x-editor"
93
+ value={draft}
94
+ onChange={(e) => setDraft(e.target.value)}
95
+ onBlur={commit}
96
+ onKeyDown={(e) => {
97
+ if (e.key === 'Enter') {
98
+ e.preventDefault();
99
+ commit(); // completeOnEnter
100
+ } else if (e.key === 'Escape') {
101
+ e.preventDefault();
102
+ cancel(); // cancelOnEsc
103
+ }
104
+ }}
105
+ style={{
106
+ position: 'absolute',
107
+ left: 0,
108
+ top: 0,
109
+ width: '100%',
110
+ height: '100%',
111
+ boxSizing: 'border-box',
112
+ fontSize: 12,
113
+ zIndex: 10,
114
+ }}
115
+ />
116
+ )}
117
+ </span>
118
+ );
119
+ };
120
+
121
+ /* ---------------- 可编辑 DataView(DataView + more 包) ---------------- */
122
+
123
+ export interface DataViewMoreItem {
124
+ id: string | number;
125
+ name: string;
126
+ sub?: string;
127
+ color?: string;
128
+ [key: string]: unknown;
129
+ }
130
+
131
+ export interface DataViewMoreProps {
132
+ data: DataViewMoreItem[];
133
+ /** 橡皮筋框选需 multiSelect(对齐 DragSelector 一般配合 multiSelect 使用) */
134
+ multiSelect?: boolean;
135
+ width?: number;
136
+ height?: number;
137
+ /** 标题编辑完成:等价 LabelEditor 的 onSave -> record.set(dataIndex, value) */
138
+ onLabelCommit?: (id: string | number, name: string) => void;
139
+ /** 选中变化回调(对齐 Ext DataView 的 selectionchange:fn(selectedIndexes)) */
140
+ onSelectionChange?: (indexes: number[]) => void;
141
+ emptyText?: string;
142
+ }
143
+
144
+ /**
145
+ * DataViewMore —— 在 base DataView 之上叠加 LabelEditor,形成「可内联重命名的图标网格」。
146
+ * 条目渲染卡片(色块 + span.x-editable 标题 + 副标题);点击标题可原地改名;
147
+ * 在空白处拖拽可框选多个条目(DragSelector)。
148
+ */
149
+ export const DataViewMore: React.FC<DataViewMoreProps> = ({
150
+ data,
151
+ multiSelect = true,
152
+ width,
153
+ height,
154
+ onLabelCommit,
155
+ onSelectionChange,
156
+ emptyText,
157
+ }) => {
158
+ const [items, setItems] = useState<DataViewMoreItem[]>(() => data);
159
+ const [selectedIdx, setSelectedIdx] = useState<number[]>([]);
160
+ const [overIdx, setOverIdx] = useState<number | null>(null);
161
+ const [drag, setDrag] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
162
+ const [dragActive, setDragActive] = useState(false);
163
+ const containerRef = useRef<HTMLDivElement>(null);
164
+ const dragStart = useRef<{ x: number; y: number } | null>(null);
165
+ const itemsRef = useRef<DataViewMoreItem[]>(items);
166
+ itemsRef.current = items;
167
+ const selectedRef = useRef<number[]>(selectedIdx);
168
+ selectedRef.current = selectedIdx;
169
+
170
+ const commitName = useCallback(
171
+ (id: string | number, name: string) => {
172
+ setItems((prev) => prev.map((it) => (it.id === id ? { ...it, name } : it)));
173
+ onLabelCommit?.(id, name);
174
+ },
175
+ [onLabelCommit],
176
+ );
177
+
178
+ const selectSingle = useCallback(
179
+ (index: number, additive: boolean) => {
180
+ setSelectedIdx((prev) => {
181
+ const next = additive && multiSelect
182
+ ? prev.includes(index)
183
+ ? prev.filter((i) => i !== index)
184
+ : [...prev, index]
185
+ : [index];
186
+ onSelectionChange?.(next);
187
+ return next;
188
+ });
189
+ },
190
+ [multiSelect, onSelectionChange],
191
+ );
192
+
193
+ // 橡皮筋框选:mousedown 在容器空白(非 item / 非 editable 标签)开始
194
+ const onMouseDown = useCallback(
195
+ (e: React.MouseEvent<HTMLDivElement>) => {
196
+ const target = e.target as HTMLElement;
197
+ if (target.closest('.x-view-item')) return; // 点在条目上走 click 逻辑
198
+ if (!multiSelect) return;
199
+ const rect = containerRef.current!.getBoundingClientRect();
200
+ const x = e.clientX - rect.left;
201
+ const y = e.clientY - rect.top;
202
+ dragStart.current = { x, y };
203
+ setDrag({ x, y, w: 0, h: 0 });
204
+ setDragActive(true);
205
+ },
206
+ [multiSelect],
207
+ );
208
+
209
+ // 框选进行中:window 级监听,保证在容器外松手也能完成(等价 Ext.dd.DragTracker 绑 document)
210
+ useEffect(() => {
211
+ if (!dragActive) return;
212
+ const onMove = (e: MouseEvent) => {
213
+ if (!dragStart.current) return;
214
+ const rect = containerRef.current!.getBoundingClientRect();
215
+ const x = e.clientX - rect.left;
216
+ const y = e.clientY - rect.top;
217
+ const { x: sx, y: sy } = dragStart.current;
218
+ setDrag({
219
+ x: Math.min(sx, x),
220
+ y: Math.min(sy, y),
221
+ w: Math.abs(x - sx),
222
+ h: Math.abs(y - sy),
223
+ });
224
+ };
225
+ const onUp = () => {
226
+ const d = dragStart.current;
227
+ const cur = drag;
228
+ dragStart.current = null;
229
+ setDragActive(false);
230
+ if (!d || !cur) {
231
+ setDrag(null);
232
+ return;
233
+ }
234
+ const container = containerRef.current!;
235
+ const cRect = container.getBoundingClientRect();
236
+ const selRect = { left: cur.x, top: cur.y, right: cur.x + cur.w, bottom: cur.y + cur.h };
237
+ const hit: number[] = [];
238
+ container.querySelectorAll<HTMLElement>('.x-view-item').forEach((el, i) => {
239
+ const r = el.getBoundingClientRect();
240
+ const rel = {
241
+ left: r.left - cRect.left,
242
+ top: r.top - cRect.top,
243
+ right: r.right - cRect.left,
244
+ bottom: r.bottom - cRect.top,
245
+ };
246
+ const intersects =
247
+ rel.right >= selRect.left && rel.left <= selRect.right &&
248
+ rel.bottom >= selRect.top && rel.top <= selRect.bottom;
249
+ if (intersects) hit.push(i);
250
+ });
251
+ setSelectedIdx((prev) => {
252
+ const next = multiSelect ? Array.from(new Set([...prev, ...hit])) : hit;
253
+ onSelectionChange?.(next);
254
+ return next;
255
+ });
256
+ setDrag(null);
257
+ };
258
+ window.addEventListener('mousemove', onMove);
259
+ window.addEventListener('mouseup', onUp);
260
+ return () => {
261
+ window.removeEventListener('mousemove', onMove);
262
+ window.removeEventListener('mouseup', onUp);
263
+ };
264
+ }, [dragActive, drag, multiSelect, onSelectionChange]);
265
+
266
+ return (
267
+ <div
268
+ ref={containerRef}
269
+ className="x-data-view"
270
+ style={{ position: 'relative', width, height, userSelect: 'none', overflow: 'auto' }}
271
+ onMouseDown={onMouseDown}
272
+ >
273
+ {items.length === 0 && emptyText != null ? (
274
+ <div className="x-view-empty" style={{ padding: '10px 8px', fontSize: 12 }}>
275
+ {emptyText}
276
+ </div>
277
+ ) : (
278
+ items.map((item, i) => (
279
+ <div
280
+ key={item.id}
281
+ className={[
282
+ 'x-view-item',
283
+ selectedIdx.includes(i) ? 'x-list-selected' : '',
284
+ overIdx === i ? 'x-view-over' : '',
285
+ ]
286
+ .filter(Boolean)
287
+ .join(' ')}
288
+ onClick={(e) => selectSingle(i, (e as React.MouseEvent).ctrlKey)}
289
+ onMouseEnter={() => setOverIdx(i)}
290
+ onMouseLeave={() => setOverIdx((o) => (o === i ? null : o))}
291
+ style={{
292
+ width: 120,
293
+ margin: 6,
294
+ padding: '8px 6px',
295
+ float: 'left',
296
+ textAlign: 'center',
297
+ borderRadius: 4,
298
+ }}
299
+ >
300
+ <div
301
+ style={{
302
+ width: 48,
303
+ height: 48,
304
+ margin: '0 auto 6px',
305
+ borderRadius: 8,
306
+ background: item.color,
307
+ border: '1px solid rgba(0,0,0,.15)',
308
+ }}
309
+ />
310
+ <div style={{ fontSize: 12 }}>
311
+ <DataViewLabelEditor value={item.name} onCommit={(v) => commitName(item.id, v)} />
312
+ </div>
313
+ {item.sub && (
314
+ <div style={{ fontSize: 11, marginTop: 2 }}>{item.sub}</div>
315
+ )}
316
+ </div>
317
+ ))
318
+ )}
319
+ {drag && (
320
+ <div
321
+ className="x-view-selector"
322
+ style={{
323
+ position: 'absolute',
324
+ left: drag.x,
325
+ top: drag.y,
326
+ width: drag.w,
327
+ height: drag.h,
328
+ }}
329
+ />
330
+ )}
331
+ </div>
332
+ );
333
+ };
334
+
335
+ (DataViewMore as unknown as { displayName?: string }).displayName = 'DataViewMore';
336
+
337
+ export default DataViewMore;
@@ -0,0 +1,235 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+
3
+ /**
4
+ * Ext.ux.DataViewTransition 保真复刻(来源 examples/ux/DataViewTransition.js)。
5
+ *
6
+ * 保真契约(视觉/行为对齐 Ext 插件,而非照搬其 store/tpl/plugin 体系):
7
+ * - 容器 div.x-data-view.x-data-view-transition(position:relative,承载绝对定位的条目)。
8
+ * - 悬停项追加 x-view-over(对齐 Ext DataView overClass 标记,与 DataView.tsx 一致)。
9
+ * - 每个条目渲染为 div.x-view-item(itemSelector 等价),以 id 为 key 稳定挂载,
10
+ * 绝对定位到网格坐标 (row*itemHeight, col*itemWidth)。
11
+ * - 数据集合变化(增/删/重排)时:
12
+ * * 仍存在的条目平滑滑动到新网格坐标(top/left CSS transition,duration 对齐 Ext 默认 750ms);
13
+ * * 被移除的条目淡出(opacity→0)后卸载;
14
+ * * 新增的条目淡入(opacity 0→1)。
15
+ * 与 Ext 插件一致:首次渲染无动画,仅后续 datachanged 才动画。
16
+ *
17
+ * React 与 Ext 差异说明(刻意修正,不影响保真):
18
+ * - Ext 用 store + tpl + itemSelector + idProperty 维护条目身份;此处用不可变的 items 数组 +
19
+ * 稳定的 id 字段(idProperty 默认 'id')表达同一语义,由 React key 维持身份。
20
+ * - Ext 用 Ext.TaskMgr 逐帧插值;此处用 CSS transition 由浏览器合成,结果一致且更省。
21
+ */
22
+
23
+ export interface TransitionItem {
24
+ /** 稳定唯一 id(等价 Ext idProperty,供跨渲染维持条目身份 / 动画定位) */
25
+ id: string;
26
+ /** 默认渲染文本(renderItem 未提供时展示) */
27
+ text?: string;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface DataViewTransitionProps {
32
+ /** 数据集合(等价 Ext store 经 filter/sort 后的 records;顺序即网格从左到右、从上到下的顺序) */
33
+ items: TransitionItem[];
34
+ /** 动画时长 ms(对齐 Ext defaults.duration = 750) */
35
+ duration?: number;
36
+ /** 身份字段名(默认 'id') */
37
+ idProperty?: string;
38
+ /** 单个条目外框宽(含 margin/gap,用于计算列数与步进;对齐 Ext itemWidth) */
39
+ itemWidth: number;
40
+ /** 单个条目外框高(含 margin/gap;对齐 Ext itemHeight) */
41
+ itemHeight: number;
42
+ /** 容器宽(px);用于计算列数 floor(width/itemWidth) */
43
+ width?: number;
44
+ /** 容器高(px);省略时取 rows*itemHeight 自适应 */
45
+ height?: number;
46
+ /** 自定义条目渲染(等价 Ext tpl);缺省渲染 item.text */
47
+ renderItem?: (item: TransitionItem) => React.ReactNode;
48
+ /** 追加在容器根上的 class */
49
+ className?: string;
50
+ }
51
+
52
+ interface RenderEntry {
53
+ id: string;
54
+ item: TransitionItem;
55
+ leaving: boolean;
56
+ }
57
+
58
+ export const DataViewTransition: React.FC<DataViewTransitionProps> = ({
59
+ items,
60
+ duration = 750,
61
+ idProperty = 'id',
62
+ itemWidth,
63
+ itemHeight,
64
+ width,
65
+ height,
66
+ renderItem,
67
+ className = '',
68
+ }) => {
69
+ const containerRef = useRef<HTMLDivElement>(null);
70
+ const [rendered, setRendered] = useState<RenderEntry[]>(() =>
71
+ items.map((it) => ({ id: it[idProperty] as string, item: it, leaving: false })),
72
+ );
73
+ const [positions, setPositions] = useState<Record<string, { top: number; left: number }>>(() => {
74
+ const containerW = width ?? 600;
75
+ const cols = Math.max(1, Math.floor(containerW / itemWidth));
76
+ const p: Record<string, { top: number; left: number }> = {};
77
+ items.forEach((it, i) => {
78
+ const row = Math.floor(i / cols);
79
+ const col = i % cols;
80
+ p[it[idProperty] as string] = { top: row * itemHeight, left: col * itemWidth };
81
+ });
82
+ return p;
83
+ });
84
+ const [opacity, setOpacity] = useState<Record<string, number>>(() => {
85
+ const o: Record<string, number> = {};
86
+ items.forEach((it) => (o[it[idProperty] as string] = 1));
87
+ return o;
88
+ });
89
+ // 悬停项(对齐 Ext DataView overClass):mouseover/out 切换 x-view-over(与 DataView.tsx 一致)。
90
+ const [overId, setOverId] = useState<string | null>(null);
91
+ // 上一帧的 items,用于 diff 出 added / removed(避免把 rendered 放进 effect 依赖引发重渲染循环)
92
+ const prevItemsRef = useRef<TransitionItem[]>(items);
93
+
94
+ // 计算网格坐标:列数 = floor(容器宽 / 条目宽)
95
+ const layout = useCallback(
96
+ (list: TransitionItem[]) => {
97
+ const containerW = width ?? containerRef.current?.clientWidth ?? 600;
98
+ const cols = Math.max(1, Math.floor(containerW / itemWidth));
99
+ const pos: Record<string, { top: number; left: number }> = {};
100
+ list.forEach((it, i) => {
101
+ const row = Math.floor(i / cols);
102
+ const col = i % cols;
103
+ pos[it[idProperty] as string] = { top: row * itemHeight, left: col * itemWidth };
104
+ });
105
+ return pos;
106
+ },
107
+ [width, itemWidth, itemHeight, idProperty],
108
+ );
109
+
110
+ useEffect(() => {
111
+ const idOf = (it: TransitionItem) => it[idProperty] as string;
112
+ const newIds = new Set(items.map(idOf));
113
+ const prevItems = prevItemsRef.current;
114
+ const prevIds = new Set(prevItems.map(idOf));
115
+ const pos = layout(items);
116
+
117
+ const staying = items.filter((it) => prevIds.has(idOf(it)));
118
+ const added = items.filter((it) => !prevIds.has(idOf(it)));
119
+ const leavingData = prevItems.filter((it) => !newIds.has(idOf(it)));
120
+
121
+ const nextRendered: RenderEntry[] = [
122
+ ...staying.map((it) => ({ id: idOf(it), item: it, leaving: false })),
123
+ ...leavingData.map((it) => ({ id: idOf(it), item: it, leaving: true })),
124
+ ...added.map((it) => ({ id: idOf(it), item: it, leaving: false })),
125
+ ];
126
+ setRendered(nextRendered);
127
+ setPositions((prev) => ({ ...prev, ...pos }));
128
+
129
+ // 保留项与新增项目标 opacity=1;移除项 opacity=0(淡出)
130
+ setOpacity((prev) => {
131
+ const o = { ...prev };
132
+ items.forEach((it) => (o[idOf(it)] = 1));
133
+ leavingData.forEach((it) => (o[idOf(it)] = 0));
134
+ return o;
135
+ });
136
+
137
+ if (added.length) {
138
+ // 新增项先以 opacity=0 挂载,下一帧淡入
139
+ setOpacity((prev) => {
140
+ const o = { ...prev };
141
+ added.forEach((it) => (o[idOf(it)] = 0));
142
+ return o;
143
+ });
144
+ const raf = requestAnimationFrame(() => {
145
+ setOpacity((prev) => {
146
+ const o = { ...prev };
147
+ added.forEach((it) => (o[idOf(it)] = 1));
148
+ return o;
149
+ });
150
+ });
151
+ leavingData.forEach((it) => scheduleRemove(idOf(it)));
152
+ prevItemsRef.current = items;
153
+ return () => cancelAnimationFrame(raf);
154
+ }
155
+
156
+ leavingData.forEach((it) => scheduleRemove(idOf(it)));
157
+ prevItemsRef.current = items;
158
+
159
+ function scheduleRemove(id: string) {
160
+ window.setTimeout(() => {
161
+ setRendered((r) => r.filter((x) => x.id !== id));
162
+ setOpacity((o) => {
163
+ const n = { ...o };
164
+ delete n[id];
165
+ return n;
166
+ });
167
+ setPositions((p) => {
168
+ const n = { ...p };
169
+ delete n[id];
170
+ return n;
171
+ });
172
+ }, duration);
173
+ }
174
+ }, [items, duration, idProperty, layout]);
175
+
176
+ const containerH =
177
+ height ??
178
+ Math.max(
179
+ 1,
180
+ Math.ceil(
181
+ Math.max(rendered.length, items.length) /
182
+ Math.max(1, Math.floor((width ?? 600) / itemWidth)),
183
+ ) * itemHeight,
184
+ );
185
+
186
+ return (
187
+ <div
188
+ ref={containerRef}
189
+ className={['x-data-view', 'x-data-view-transition', className].filter(Boolean).join(' ')}
190
+ style={{
191
+ position: 'relative',
192
+ width,
193
+ height: containerH,
194
+ overflow: height != null ? 'auto' : 'hidden',
195
+ }}
196
+ >
197
+ {rendered.map((e) => {
198
+ const pos = positions[e.id] ?? { top: 0, left: 0 };
199
+ const op = opacity[e.id] ?? 1;
200
+ return (
201
+ <div
202
+ key={e.id}
203
+ className={[
204
+ 'x-view-item',
205
+ e.leaving ? 'x-view-item-leaving' : '',
206
+ overId === e.id ? 'x-view-over' : '',
207
+ ]
208
+ .filter(Boolean)
209
+ .join(' ')}
210
+ onMouseEnter={() => setOverId(e.id)}
211
+ onMouseLeave={() => setOverId((o) => (o === e.id ? null : o))}
212
+ style={{
213
+ position: 'absolute',
214
+ top: pos.top,
215
+ left: pos.left,
216
+ width: itemWidth,
217
+ height: itemHeight,
218
+ opacity: op,
219
+ transition: `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`,
220
+ boxSizing: 'border-box',
221
+ textAlign: 'center',
222
+ overflow: 'hidden',
223
+ }}
224
+ >
225
+ {renderItem ? renderItem(e.item) : (e.item.text ?? '')}
226
+ </div>
227
+ );
228
+ })}
229
+ </div>
230
+ );
231
+ };
232
+
233
+ (DataViewTransition as unknown as { displayName?: string }).displayName = 'DataViewTransition';
234
+
235
+ export default DataViewTransition;