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,809 @@
1
+ /**
2
+ * Ext.tree.TreePanel / TreeNode 保真复刻(来源 src/widgets/tree/TreePanel.js、TreeNode.js、TreeNodeUI.js)。
3
+ *
4
+ * 保真契约(DOM/class 严格对齐源码):
5
+ * - 面板根:ul.x-tree-root-ct(+ x-tree-lines / x-tree-arrows / x-tree-no-lines),外层 x-tree 落在 panel body 上。
6
+ * - 节点 li.x-tree-node
7
+ * > div.x-tree-node-el
8
+ * > span.x-tree-node-indent(祖先缩进:祖先非末位→x-tree-elbow-line,末位→x-tree-icon)
9
+ * > img.x-tree-ec-icon(自身展开/折叠柄:elbow[-end][-plus/-minus])
10
+ * > img.x-tree-node-icon(folder/folder-open/leaf 由 CSS 按 x-tree-node-* 状态取背景图)
11
+ * > [可选] input.x-tree-node-cb(复选框)
12
+ * > a.x-tree-node-anchor > span(文字)
13
+ * > ul.x-tree-node-ct(子节点;展开 block / 折叠 none)
14
+ * - 节点 el 状态 class:x-tree-node-leaf / x-tree-node-expanded / x-tree-node-collapsed / x-tree-node-over / x-tree-selected / x-tree-node-disabled
15
+ * - ec-icon:自身末位→x-tree-elbow-end,否则 x-tree-elbow;有子或 expandable 时追加 -plus(折叠)/-minus(展开)
16
+ *
17
+ * 交互(行为契约):
18
+ * - 展开/折叠:点击 ec-icon 切换 expanded(翻转 el 的 expanded/collapsed、ec-icon 的 plus/minus、ct 的 display)
19
+ * - 选中:点击节点 → 加 x-tree-selected(同级唯一),移除兄弟选中 —— CSS 给 #d9e8fb 背景
20
+ * - 悬停:onMouseEnter 加 x-tree-node-over,onMouseLeave 移除 —— CSS 给 #eee 背景
21
+ * - 禁用:x-tree-node-disabled 灰字 + 图标半透明,不可选中/展开
22
+ * 说明:over/selected 背景是 CSS 对 class 的着色,与 combo 一样靠 JS 切 class(非 :hover 默认),此处复刻为 React 状态。
23
+ */
24
+ import { useState, useRef, useMemo, forwardRef, useImperativeHandle, type CSSProperties } from 'react';
25
+
26
+ const BLANK_IMG = '/extjs-assets/images/default/s.gif';
27
+
28
+ export interface TreeNodeData {
29
+ id: string;
30
+ text: string;
31
+ /** 自定义图标 class(加到 node-icon 上,由 CSS 提供背景图) */
32
+ iconCls?: string;
33
+ /** 图标图片路径(覆盖默认 folder/leaf 图标) */
34
+ icon?: string;
35
+ /** 初始是否展开 */
36
+ expanded?: boolean;
37
+ /** 强制显示 +/- 展开柄(即使无子节点) */
38
+ expandable?: boolean;
39
+ /** 初始是否禁用 */
40
+ disabled?: boolean;
41
+ /** 复选框:true 渲染选中框,false 渲染未选框,undefined 不渲染 */
42
+ checked?: boolean;
43
+ /** 附加 class(落在 x-tree-node-el 上) */
44
+ cls?: string;
45
+ /** 子节点 */
46
+ children?: TreeNodeData[];
47
+ }
48
+
49
+ export interface TreePanelProps {
50
+ /** 是否显示根节点,默认 true(与 Ext 默认一致)。false 时直接渲染 root.children。 */
51
+ rootVisible?: boolean;
52
+ /** 是否显示连接线,默认 true(x-tree-lines)。false 且无 useArrows → x-tree-no-lines。 */
53
+ lines?: boolean;
54
+ /** 使用箭头样式替代 +/-(x-tree-arrows) */
55
+ useArrows?: boolean;
56
+ /** 根节点数据(rootVisible:false 时仅用其 children) */
57
+ root?: TreeNodeData;
58
+ /** 顶层节点(与 root 二选一;提供时等效于 rootVisible:false) */
59
+ nodes?: TreeNodeData[];
60
+ /** 选中回调 */
61
+ onSelect?: (node: TreeNodeData) => void;
62
+ /** 展开/折叠回调 */
63
+ onExpand?: (node: TreeNodeData, expanded: boolean) => void;
64
+ /**
65
+ * 异步子节点加载器:当展开一个 expandable 但尚无子节点的节点时调用,
66
+ * 返回的子节点将在 resolve 后写入并展开(等价于 Ext AsyncTreeNode / TreeLoader)。
67
+ */
68
+ loader?: (node: TreeNodeData) => Promise<TreeNodeData[]>;
69
+ /**
70
+ * 启用节点拖拽重排(对齐 Ext TreePanel enableDD)。拖动节点可在同级间「上方/下方」移动,
71
+ * 拖到文件夹节点的中部区域则「作为其子节点」插入。仅对静态树生效(含异步加载的树不重排异步子树)。
72
+ */
73
+ enableNodeReorder?: boolean;
74
+ /** 节点重排完成回调(dragId, targetId, mode) */
75
+ onNodeMove?: (dragId: string, targetId: string, mode: 'above' | 'below' | 'append') => void;
76
+ /** 允许内联改名(双击节点或命令式 startEdit 触发,等价 Ext.tree.TreeEditor) */
77
+ editable?: boolean;
78
+ /** 内联改名提交回调(等价 TreeEditor 的 onComplete) */
79
+ onRename?: (id: string, text: string) => void;
80
+ style?: CSSProperties;
81
+ }
82
+
83
+ interface RenderCtx {
84
+ lines: boolean;
85
+ useArrows: boolean;
86
+ selectedId: string | null;
87
+ expandedIds: Record<string, boolean>;
88
+ ancestorsLast: boolean[];
89
+ onSelect: (node: TreeNodeData) => void;
90
+ onToggle: (id: string, node: TreeNodeData) => void;
91
+ /** 取节点的有效子节点(含异步加载后写入的子节点覆盖) */
92
+ getChildren: (node: TreeNodeData) => TreeNodeData[] | undefined;
93
+ /** 节点是否正在异步加载子节点 */
94
+ isLoading: (id: string) => boolean;
95
+ /** 取节点的勾选状态(含级联后写入的覆盖) */
96
+ getChecked: (node: TreeNodeData) => boolean;
97
+ /** 勾选切换(含父子级联) */
98
+ onCheck: (node: TreeNodeData, checked: boolean) => void;
99
+ /** 是否启用节点拖拽重排 */
100
+ enableNodeReorder: boolean;
101
+ /** 当前拖拽落点高亮节点 id(append 模式) */
102
+ dropTargetId: string | null;
103
+ /** 当前拖拽落点模式 */
104
+ dropMode: 'above' | 'below' | 'append' | null;
105
+ /** 当前拖拽插入落点(above/below 模式):供节点 el 挂原生 x-tree-drag-insert-* 类 */
106
+ insertTarget: { id: string; mode: 'above' | 'below' } | null;
107
+ /** 节点 el 鼠标按下:发起拖拽 */
108
+ onNodeMouseDown: (id: string, e: React.MouseEvent) => void;
109
+ /** 是否允许内联改名 */
110
+ editable: boolean;
111
+ /** 当前正在改名的节点 id(null 表示无) */
112
+ editingId: string | null;
113
+ /** 进入改名态(传入 '' 取消当前改名) */
114
+ startEdit: (id: string) => void;
115
+ /** 提交改名(写回并退出改名态) */
116
+ commitRename: (id: string, text: string) => void;
117
+ }
118
+
119
+ // ---- 节点拖拽重排:树结构操作工具(纯函数,返回新树,不修改入参) ----
120
+ function findNode(nodes: TreeNodeData[], id: string): TreeNodeData | null {
121
+ for (const n of nodes) {
122
+ if (n.id === id) return n;
123
+ if (n.children) {
124
+ const f = findNode(n.children, id);
125
+ if (f) return f;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function cloneTree(nodes: TreeNodeData[]): TreeNodeData[] {
132
+ return nodes.map((n) => ({ ...n, children: n.children ? cloneTree(n.children) : n.children }));
133
+ }
134
+
135
+ function removeNode(nodes: TreeNodeData[], id: string): TreeNodeData | null {
136
+ for (let i = 0; i < nodes.length; i++) {
137
+ if (nodes[i].id === id) {
138
+ const [r] = nodes.splice(i, 1);
139
+ return r;
140
+ }
141
+ if (nodes[i].children) {
142
+ const r = removeNode(nodes[i].children!, id);
143
+ if (r) return r;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function getSiblingsAndIndex(
150
+ nodes: TreeNodeData[],
151
+ id: string,
152
+ ): { siblings: TreeNodeData[]; index: number } | null {
153
+ for (const n of nodes) {
154
+ const idx = n.children?.findIndex((c) => c.id === id);
155
+ if (idx !== undefined && idx >= 0) return { siblings: n.children!, index: idx };
156
+ if (n.children) {
157
+ const r = getSiblingsAndIndex(n.children, id);
158
+ if (r) return r;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** 将 dragId 节点移动到 targetId 节点的上方/下方(同级重排) */
165
+ function moveSibling(
166
+ tree: TreeNodeData[],
167
+ dragId: string,
168
+ targetId: string,
169
+ mode: 'above' | 'below',
170
+ ): TreeNodeData[] {
171
+ const t = cloneTree(tree);
172
+ // 不能拖入自身子树
173
+ const dragNode = findNode(t, dragId);
174
+ if (!dragNode) return tree;
175
+ if (dragId === targetId) return tree;
176
+ const targetNode = findNode(t, targetId);
177
+ if (!targetNode) return tree;
178
+ // 判断 target 是否位于 drag 子树内
179
+ if (dragNode.children && findNode(dragNode.children, targetId)) return tree;
180
+ const drag = removeNode(t, dragId);
181
+ if (!drag) return tree;
182
+ const si = getSiblingsAndIndex(t, targetId);
183
+ const siblings = si ? si.siblings : t;
184
+ const idx = siblings.findIndex((s) => s.id === targetId);
185
+ const insertAt = mode === 'above' ? idx : idx + 1;
186
+ siblings.splice(insertAt, 0, drag);
187
+ return t;
188
+ }
189
+
190
+ /** 将 dragId 节点作为 targetId 文件夹节点的子节点追加 */
191
+ function moveInto(tree: TreeNodeData[], dragId: string, targetId: string): TreeNodeData[] {
192
+ const t = cloneTree(tree);
193
+ const dragNode = findNode(t, dragId);
194
+ if (!dragNode || dragId === targetId) return tree;
195
+ if (dragNode.children && findNode(dragNode.children, targetId)) return tree;
196
+ const drag = removeNode(t, dragId);
197
+ if (!drag) return tree;
198
+ const target = findNode(t, targetId);
199
+ if (!target) return tree;
200
+ target.children = target.children ?? [];
201
+ target.expanded = true;
202
+ target.expandable = true;
203
+ target.children.push(drag);
204
+ return t;
205
+ }
206
+
207
+ /** 纯函数:将 node 追加为 parentId(null = 根层)的子节点,返回新树 */
208
+ function appendChildPure(tree: TreeNodeData[], parentId: string | null, node: TreeNodeData): TreeNodeData[] {
209
+ if (parentId === null) return [...tree, node];
210
+ const t = cloneTree(tree);
211
+ const p = findNode(t, parentId);
212
+ if (!p) return tree;
213
+ p.children = p.children ?? [];
214
+ p.children.push(node);
215
+ p.expandable = true;
216
+ return t;
217
+ }
218
+
219
+ /** 纯函数:删除 id 对应节点,返回新树(不修改入参) */
220
+ function removeNodePure(nodes: TreeNodeData[], id: string): TreeNodeData[] {
221
+ const out: TreeNodeData[] = [];
222
+ for (const n of nodes) {
223
+ if (n.id === id) continue;
224
+ const kids = n.children ? removeNodePure(n.children, id) : n.children;
225
+ out.push({ ...n, children: kids });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** 纯函数:在 targetId 之前插入 node,返回新树 */
231
+ function insertBeforePure(nodes: TreeNodeData[], targetId: string, node: TreeNodeData): TreeNodeData[] {
232
+ const out: TreeNodeData[] = [];
233
+ for (const n of nodes) {
234
+ if (n.id === targetId) out.push(node);
235
+ const kids = n.children ? insertBeforePure(n.children, targetId, node) : n.children;
236
+ out.push({ ...n, children: kids });
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /** 纯函数:合并 partial 到 id 节点,返回新树 */
242
+ function updateNodePure(nodes: TreeNodeData[], id: string, partial: Partial<TreeNodeData>): TreeNodeData[] {
243
+ return nodes.map((n) => {
244
+ if (n.id === id) return { ...n, ...partial };
245
+ const kids = n.children ? updateNodePure(n.children, id, partial) : n.children;
246
+ return { ...n, children: kids };
247
+ });
248
+ }
249
+
250
+ function collectInitialExpanded(nodes: TreeNodeData[], acc: Record<string, boolean>) {
251
+ for (const n of nodes) {
252
+ if (n.expanded) acc[n.id] = true;
253
+ if (n.children) collectInitialExpanded(n.children, acc);
254
+ }
255
+ }
256
+
257
+ function collectInitialChecked(nodes: TreeNodeData[], acc: Record<string, boolean>) {
258
+ for (const n of nodes) {
259
+ if (n.checked !== undefined) acc[n.id] = !!n.checked;
260
+ if (n.children) collectInitialChecked(n.children, acc);
261
+ }
262
+ }
263
+
264
+ function TreeBranch({ node, isLast, ctx }: { node: TreeNodeData; isLast: boolean; ctx: RenderCtx }) {
265
+ const [hovered, setHovered] = useState(false);
266
+
267
+ const effectiveChildren = ctx.getChildren(node);
268
+ const hasChildren = !!(effectiveChildren && effectiveChildren.length);
269
+ const loading = ctx.isLoading(node.id);
270
+ const expandable = hasChildren || node.expandable === true || loading;
271
+ const expanded = !!ctx.expandedIds[node.id];
272
+ const selected = ctx.selectedId === node.id;
273
+ const disabled = !!node.disabled;
274
+
275
+ // 拖拽落点:append → 原生 x-tree-drag-append 类(节点高亮);above/below → 原生
276
+ // x-tree-drag-insert-above/below 类(点状主题色边框)。均复用原生树拖拽落点机制,不写死颜色。
277
+ const isDropAppendTarget =
278
+ ctx.enableNodeReorder && ctx.dropTargetId === node.id && ctx.dropMode === 'append';
279
+ const isDropInsertTarget = ctx.enableNodeReorder && ctx.insertTarget?.id === node.id;
280
+
281
+ // 节点 el 状态 class:可展开(有子或显式 expandable)即为 folder,否则 leaf
282
+ const elCls = [
283
+ 'x-tree-node-el',
284
+ expandable ? (expanded ? 'x-tree-node-expanded' : 'x-tree-node-collapsed') : 'x-tree-node-leaf',
285
+ hovered && !disabled ? 'x-tree-node-over' : '',
286
+ selected ? 'x-tree-selected' : '',
287
+ disabled ? 'x-tree-node-disabled' : '',
288
+ loading ? 'x-tree-node-loading' : '',
289
+ 'x-unselectable',
290
+ node.cls || '',
291
+ isDropAppendTarget ? 'x-tree-drag-append' : '',
292
+ isDropInsertTarget ? `x-tree-drag-insert-${ctx.insertTarget!.mode}` : '',
293
+ ]
294
+ .filter(Boolean)
295
+ .join(' ');
296
+
297
+ // ec-icon(展开/折叠柄):自身末位决定 elbow / elbow-end,有子或 expandable 决定 +/- 后缀
298
+ const ecBase = isLast ? 'x-tree-elbow-end' : 'x-tree-elbow';
299
+ const ecSuffix = expandable ? (expanded ? '-minus' : '-plus') : '';
300
+ const ecCls = ['x-tree-ec-icon', `${ecBase}${ecSuffix}`].join(' ');
301
+
302
+ // 图标 class
303
+ const iconCls = [
304
+ 'x-tree-node-icon',
305
+ node.icon ? 'x-tree-node-inline-icon' : '',
306
+ node.iconCls || '',
307
+ ]
308
+ .filter(Boolean)
309
+ .join(' ');
310
+
311
+ // 祖先缩进:每一个祖先贡献一张图,末位祖先→x-tree-icon(空白),否则→x-tree-elbow-line
312
+ const indentImgs = ctx.ancestorsLast.map((aLast) =>
313
+ aLast ? 'x-tree-icon' : 'x-tree-elbow-line',
314
+ );
315
+
316
+ const handleSelect = () => {
317
+ if (disabled) return;
318
+ ctx.onSelect(node);
319
+ };
320
+
321
+ const isEditing = ctx.editable && ctx.editingId === node.id;
322
+ const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
323
+ if (e.key === 'Enter') {
324
+ e.preventDefault();
325
+ ctx.commitRename(node.id, e.currentTarget.value);
326
+ } else if (e.key === 'Escape') {
327
+ e.preventDefault();
328
+ ctx.startEdit(''); // 取消改名
329
+ }
330
+ };
331
+
332
+ const handleEcClick = (e: React.MouseEvent) => {
333
+ e.stopPropagation();
334
+ if (disabled || !expandable || loading) return;
335
+ ctx.onToggle(node.id, node);
336
+ };
337
+
338
+ // 拖拽落点为 append 模式时,由 elCls 上的原生 x-tree-drag-append 类表达高亮(零新增 CSS、不写死色)
339
+
340
+ return (
341
+ <li className="x-tree-node">
342
+ <div
343
+ className={elCls}
344
+ unselectable="on"
345
+ data-node-id={node.id}
346
+ onMouseDown={(e) => ctx.onNodeMouseDown(node.id, e)}
347
+ onMouseEnter={() => setHovered(true)}
348
+ onMouseLeave={() => setHovered(false)}
349
+ onClick={handleSelect}
350
+ onDoubleClick={() => {
351
+ if (ctx.editable && !disabled) ctx.startEdit(node.id);
352
+ }}
353
+ >
354
+ <span className="x-tree-node-indent">
355
+ {indentImgs.map((c, i) => (
356
+ <img key={i} src={BLANK_IMG} className={c} alt="" />
357
+ ))}
358
+ </span>
359
+ <img
360
+ src={BLANK_IMG}
361
+ className={ecCls}
362
+ alt=""
363
+ onClick={handleEcClick}
364
+ />
365
+ <img
366
+ src={node.icon || BLANK_IMG}
367
+ className={iconCls}
368
+ unselectable="on"
369
+ alt=""
370
+ />
371
+ {node.checked !== undefined && (
372
+ <input
373
+ className="x-tree-node-cb"
374
+ type="checkbox"
375
+ checked={ctx.getChecked(node)}
376
+ disabled={disabled}
377
+ onChange={(e) => ctx.onCheck(node, e.target.checked)}
378
+ />
379
+ )}
380
+ {isEditing ? (
381
+ <input
382
+ className="x-form-text x-small-editor x-tree-node-edit"
383
+ autoFocus
384
+ defaultValue={node.text}
385
+ onMouseDown={(e) => e.stopPropagation()}
386
+ onBlur={(e) => ctx.commitRename(node.id, e.currentTarget.value)}
387
+ onKeyDown={handleRenameKey}
388
+ style={{
389
+ width: '70%',
390
+ fontSize: 12,
391
+ marginRight: 4,
392
+ boxSizing: 'border-box',
393
+ }}
394
+ />
395
+ ) : (
396
+ <a
397
+ className="x-tree-node-anchor"
398
+ href="#"
399
+ tabIndex={1}
400
+ onClick={(e) => e.preventDefault()}
401
+ >
402
+ <span unselectable="on">{node.text}</span>
403
+ </a>
404
+ )}
405
+ </div>
406
+ {(hasChildren || loading) && (
407
+ <ul
408
+ className="x-tree-node-ct"
409
+ style={{ display: expanded || loading ? 'block' : 'none' }}
410
+ >
411
+ {loading && !hasChildren && (
412
+ <li className="x-tree-node">
413
+ <div className="x-tree-node-el x-tree-node-loading">
414
+ <span className="x-tree-node-indent">
415
+ {indentImgs.map((c, i) => (
416
+ <img key={i} src={BLANK_IMG} className={c} alt="" />
417
+ ))}
418
+ </span>
419
+ <a className="x-tree-node-anchor" href="#" onClick={(e) => e.preventDefault()}>
420
+ <span unselectable="on">Loading…</span>
421
+ </a>
422
+ </div>
423
+ </li>
424
+ )}
425
+ {hasChildren && (
426
+ <TreeBranchList
427
+ nodes={effectiveChildren!}
428
+ ctx={{
429
+ ...ctx,
430
+ ancestorsLast: [...ctx.ancestorsLast, isLast],
431
+ }}
432
+ />
433
+ )}
434
+ </ul>
435
+ )}
436
+ </li>
437
+ );
438
+ }
439
+
440
+ function TreeBranchList({ nodes, ctx }: { nodes: TreeNodeData[]; ctx: RenderCtx }) {
441
+ return (
442
+ <>
443
+ {nodes.map((n, i) => (
444
+ <TreeBranch
445
+ key={n.id}
446
+ node={n}
447
+ isLast={i === nodes.length - 1}
448
+ ctx={ctx}
449
+ />
450
+ ))}
451
+ </>
452
+ );
453
+ }
454
+
455
+ export interface TreePanelHandle {
456
+ /** 追加子节点到 parentId(null = 根层),并自动展开该父节点 */
457
+ appendChild: (parentId: string | null, node: TreeNodeData) => void;
458
+ /** 删除指定 id 节点(含其子树) */
459
+ removeNode: (id: string) => void;
460
+ /** 在 targetId 之前插入节点(同级) */
461
+ insertBefore: (targetId: string, node: TreeNodeData) => void;
462
+ /** 合并更新指定 id 节点的字段 */
463
+ updateNode: (id: string, partial: Partial<TreeNodeData>) => void;
464
+ /** 读取指定 id 节点的当前数据(找不到返回 null) */
465
+ getNode: (id: string) => TreeNodeData | null;
466
+ /** 读取当前根层节点数组(实时快照) */
467
+ getNodes: () => TreeNodeData[];
468
+ /** 展开节点 */
469
+ expand: (id: string) => void;
470
+ /** 折叠节点 */
471
+ collapse: (id: string) => void;
472
+ /** 选中节点 */
473
+ select: (id: string) => void;
474
+ /** 进入内联改名态(等价 TreeEditor.startEdit);传入 '' 取消 */
475
+ startEdit: (id: string) => void;
476
+ }
477
+
478
+ export const TreePanel = forwardRef<TreePanelHandle, TreePanelProps>(function TreePanel(
479
+ {
480
+ rootVisible = true,
481
+ lines = true,
482
+ useArrows = false,
483
+ root,
484
+ nodes,
485
+ onSelect,
486
+ onExpand,
487
+ loader,
488
+ enableNodeReorder = false,
489
+ onNodeMove,
490
+ editable = false,
491
+ onRename,
492
+ style,
493
+ },
494
+ ref,
495
+ ) {
496
+ // 内部可变树(仅在 enableNodeReorder 时被拖拽改写;其余特性仍依赖 topNodes 读取原始结构)
497
+ const [treeState, setTreeState] = useState<TreeNodeData[]>(() => {
498
+ if (nodes) return nodes;
499
+ if (root) return rootVisible ? [root] : root.children ?? [];
500
+ return [];
501
+ });
502
+ const topNodes = treeState;
503
+ // 镜像最新树,供命令式句柄同步读取(避免闭包捕获旧快照)
504
+ const treeStateRef = useRef<TreeNodeData[]>(treeState);
505
+ treeStateRef.current = treeState;
506
+
507
+ useImperativeHandle(ref, () => ({
508
+ appendChild: (parentId, node) => {
509
+ setTreeState((prev) => appendChildPure(prev, parentId, node));
510
+ if (parentId) setExpandedIds((prev) => ({ ...prev, [parentId]: true }));
511
+ },
512
+ removeNode: (id) => {
513
+ setTreeState((prev) => removeNodePure(prev, id));
514
+ },
515
+ insertBefore: (targetId, node) => {
516
+ setTreeState((prev) => insertBeforePure(prev, targetId, node));
517
+ },
518
+ updateNode: (id, partial) => {
519
+ setTreeState((prev) => updateNodePure(prev, id, partial));
520
+ },
521
+ getNode: (id) => findNode(treeStateRef.current, id),
522
+ getNodes: () => treeStateRef.current,
523
+ expand: (id) => setExpandedIds((prev) => ({ ...prev, [id]: true })),
524
+ collapse: (id) => setExpandedIds((prev) => ({ ...prev, [id]: false })),
525
+ select: (id) => {
526
+ setSelectedId(id);
527
+ const n = findNode(treeStateRef.current, id);
528
+ if (n) onSelect?.(n);
529
+ },
530
+ startEdit: (id) => setEditingId(id),
531
+ }));
532
+
533
+ // 拖拽状态
534
+ const treeRef = useRef<HTMLDivElement>(null);
535
+ const dragRef = useRef<{ id: string; x: number; y: number; started: boolean } | null>(null);
536
+ const didDragRef = useRef(false);
537
+ const dropCtxRef = useRef<{ targetId: string; mode: 'above' | 'below' | 'append' } | null>(null);
538
+ const [, setDragId] = useState<string | null>(null);
539
+ const [insertTarget, setInsertTarget] = useState<{ id: string; mode: 'above' | 'below' } | null>(null);
540
+ const [dropTarget, setDropTarget] = useState<{ id: string; mode: 'append' } | null>(null);
541
+ const [ghost, setGhost] = useState<{ x: number; y: number; text: string } | null>(null);
542
+
543
+ const computeDrop = (ev: MouseEvent, dragNodeId: string) => {
544
+ const rootEl = treeRef.current;
545
+ if (!rootEl) return;
546
+ const els = Array.from(rootEl.querySelectorAll<HTMLElement>('[data-node-id]'));
547
+ let overEl: HTMLElement | null = null;
548
+ for (const el of els) {
549
+ const r = el.getBoundingClientRect();
550
+ if (ev.clientY >= r.top && ev.clientY <= r.bottom) {
551
+ overEl = el;
552
+ break;
553
+ }
554
+ }
555
+ if (!overEl) {
556
+ dropCtxRef.current = null;
557
+ setInsertTarget(null);
558
+ setDropTarget(null);
559
+ return;
560
+ }
561
+ const targetId = overEl.getAttribute('data-node-id')!;
562
+ if (targetId === dragNodeId) {
563
+ dropCtxRef.current = null;
564
+ setInsertTarget(null);
565
+ setDropTarget(null);
566
+ return;
567
+ }
568
+ const r = overEl.getBoundingClientRect();
569
+ let mode: 'above' | 'below' | 'append';
570
+ if (ev.clientY < r.top + r.height * 0.25) mode = 'above';
571
+ else if (ev.clientY > r.bottom - r.height * 0.25) mode = 'below';
572
+ else {
573
+ const node = findNode(topNodes, targetId);
574
+ mode = node && (node.children?.length || node.expandable) ? 'append' : 'below';
575
+ }
576
+ // 不能拖入自身子树
577
+ const dragNode = findNode(topNodes, dragNodeId);
578
+ if (mode === 'append' && dragNode && dragNode.children && findNode(dragNode.children, targetId)) {
579
+ dropCtxRef.current = null;
580
+ setInsertTarget(null);
581
+ setDropTarget(null);
582
+ return;
583
+ }
584
+ dropCtxRef.current = { targetId, mode };
585
+ if (mode === 'append') {
586
+ setDropTarget({ id: targetId, mode: 'append' });
587
+ setInsertTarget(null);
588
+ } else {
589
+ setInsertTarget({ id: targetId, mode });
590
+ setDropTarget(null);
591
+ }
592
+ };
593
+
594
+ const onNodeMouseDown = (id: string, e: React.MouseEvent) => {
595
+ if (!enableNodeReorder) return;
596
+ const t = e.target as HTMLElement;
597
+ if (t.closest('.x-tree-ec-icon') || t.closest('.x-tree-node-cb') || t.closest('a')) return;
598
+ if (e.button !== 0) return;
599
+ e.preventDefault();
600
+ dragRef.current = { id, x: e.clientX, y: e.clientY, started: false };
601
+ didDragRef.current = false;
602
+ const move = (ev: MouseEvent) => {
603
+ const d = dragRef.current;
604
+ if (!d) return;
605
+ if (!d.started) {
606
+ if (Math.abs(ev.clientX - d.x) < 4 && Math.abs(ev.clientY - d.y) < 4) return;
607
+ d.started = true;
608
+ didDragRef.current = true;
609
+ const node = findNode(topNodes, d.id);
610
+ setDragId(d.id);
611
+ if (node) setGhost({ x: ev.clientX, y: ev.clientY, text: node.text });
612
+ }
613
+ computeDrop(ev, d.id);
614
+ setGhost((g) => (g ? { ...g, x: ev.clientX, y: ev.clientY } : g));
615
+ };
616
+ const up = () => {
617
+ window.removeEventListener('mousemove', move);
618
+ window.removeEventListener('mouseup', up);
619
+ const d = dragRef.current;
620
+ dragRef.current = null;
621
+ const ctx = dropCtxRef.current;
622
+ if (d && d.started && ctx) {
623
+ let next = topNodes;
624
+ if (ctx.mode === 'append') next = moveInto(topNodes, d.id, ctx.targetId);
625
+ else next = moveSibling(topNodes, d.id, ctx.targetId, ctx.mode);
626
+ if (next !== topNodes) {
627
+ setTreeState(next);
628
+ onNodeMove?.(d.id, ctx.targetId, ctx.mode);
629
+ }
630
+ }
631
+ didDragRef.current = false;
632
+ setDragId(null);
633
+ setInsertTarget(null);
634
+ setDropTarget(null);
635
+ setGhost(null);
636
+ dropCtxRef.current = null;
637
+ };
638
+ window.addEventListener('mousemove', move);
639
+ window.addEventListener('mouseup', up);
640
+ };
641
+
642
+ // 防止拖拽误触选中
643
+ const handleSelectWithGuard = (node: TreeNodeData) => {
644
+ if (didDragRef.current) {
645
+ didDragRef.current = false;
646
+ return;
647
+ }
648
+ setSelectedId(node.id);
649
+ onSelect?.(node);
650
+ };
651
+
652
+ const [selectedId, setSelectedId] = useState<string | null>(null);
653
+ /** 内联改名:当前正在编辑的节点 id(null 表示无) */
654
+ const [editingId, setEditingId] = useState<string | null>(null);
655
+ const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
656
+ const acc: Record<string, boolean> = {};
657
+ if (root) collectInitialExpanded([root], acc);
658
+ if (nodes) collectInitialExpanded(nodes, acc);
659
+ return acc;
660
+ });
661
+ /** 异步加载后写入的子节点覆盖(key = 节点 id) */
662
+ const [childMap, setChildMap] = useState<Record<string, TreeNodeData[]>>({});
663
+ /** 已成功加载过子节点的节点 id */
664
+ const [loadedIds, setLoadedIds] = useState<Record<string, boolean>>({});
665
+ /** 正在异步加载子节点的节点 id */
666
+ const [loadingIds, setLoadingIds] = useState<Record<string, boolean>>({});
667
+ /** 勾选状态覆盖(key = 节点 id),初始取自数据中的 checked */
668
+ const [checkedIds, setCheckedIds] = useState<Record<string, boolean>>(() => {
669
+ const acc: Record<string, boolean> = {};
670
+ if (root) collectInitialChecked([root], acc);
671
+ if (nodes) collectInitialChecked(nodes, acc);
672
+ return acc;
673
+ });
674
+
675
+ // 父子映射(用于勾选级联向上传播);随异步加载的 childMap 变化而重建
676
+ const { parentMap, nodeById } = useMemo(() => {
677
+ const pMap: Record<string, string | null> = {};
678
+ const byId: Record<string, TreeNodeData> = {};
679
+ const walk = (ns: TreeNodeData[], parentId: string | null) => {
680
+ for (const n of ns) {
681
+ byId[n.id] = n;
682
+ pMap[n.id] = parentId;
683
+ const kids = childMap[n.id] ?? n.children;
684
+ if (kids) walk(kids, n.id);
685
+ }
686
+ };
687
+ walk(topNodes, null);
688
+ return { parentMap: pMap, nodeById: byId };
689
+ }, [topNodes, childMap]);
690
+
691
+ const rootCls = useArrows
692
+ ? 'x-tree-arrows'
693
+ : lines
694
+ ? 'x-tree-lines'
695
+ : 'x-tree-no-lines';
696
+
697
+ const ctx: RenderCtx = {
698
+ lines,
699
+ useArrows,
700
+ selectedId,
701
+ expandedIds,
702
+ ancestorsLast: [],
703
+ onSelect: handleSelectWithGuard,
704
+ onToggle: (id, node) => {
705
+ const willExpand = !expandedIds[id];
706
+ // 异步加载:展开一个尚无子节点的 expandable 节点时触发 loader
707
+ if (
708
+ willExpand &&
709
+ loader &&
710
+ !loadedIds[id] &&
711
+ !(childMap[id] && childMap[id].length) &&
712
+ !(node.children && node.children.length)
713
+ ) {
714
+ setLoadingIds((prev) => ({ ...prev, [id]: true }));
715
+ loader(node)
716
+ .then((children) => {
717
+ setChildMap((prev) => ({ ...prev, [id]: children }));
718
+ setLoadedIds((prev) => ({ ...prev, [id]: true }));
719
+ setLoadingIds((prev) => ({ ...prev, [id]: false }));
720
+ setExpandedIds((prev) => ({ ...prev, [id]: true }));
721
+ })
722
+ .catch(() => {
723
+ setLoadingIds((prev) => ({ ...prev, [id]: false }));
724
+ });
725
+ onExpand?.(node, true);
726
+ return;
727
+ }
728
+ setExpandedIds((prev) => {
729
+ const next = { ...prev };
730
+ next[id] = !next[id];
731
+ onExpand?.(node, next[id]);
732
+ return next;
733
+ });
734
+ },
735
+ getChildren: (node) => childMap[node.id] ?? node.children,
736
+ isLoading: (id) => !!loadingIds[id],
737
+ getChecked: (node) => {
738
+ const v = checkedIds[node.id];
739
+ return v !== undefined ? v : !!node.checked;
740
+ },
741
+ enableNodeReorder,
742
+ dropTargetId: dropTarget?.id ?? null,
743
+ dropMode: dropTarget ? 'append' : insertTarget ? insertTarget.mode : null,
744
+ insertTarget,
745
+ onNodeMouseDown,
746
+ editable,
747
+ editingId,
748
+ startEdit: (id: string) => setEditingId(id || null),
749
+ commitRename: (id: string, text: string) => {
750
+ setEditingId(null);
751
+ if (text && text.trim()) {
752
+ setTreeState((prev) => updateNodePure(prev, id, { text: text.trim() }));
753
+ onRename?.(id, text.trim());
754
+ }
755
+ },
756
+ onCheck: (node, checked) => {
757
+ setCheckedIds((prev) => {
758
+ const next = { ...prev };
759
+ // 1) 自身及其所有(已知)后代统一置为新值
760
+ const apply = (n: TreeNodeData) => {
761
+ next[n.id] = checked;
762
+ const kids = childMap[n.id] ?? n.children;
763
+ if (kids) kids.forEach(apply);
764
+ };
765
+ apply(node);
766
+ // 2) 向上传播:祖先 checked = 其全部已知子节点均 checked
767
+ let pid = parentMap[node.id];
768
+ while (pid != null) {
769
+ const pNode = nodeById[pid];
770
+ if (!pNode) break;
771
+ const kids = childMap[pid] ?? pNode.children;
772
+ const allChecked =
773
+ !!kids && kids.length > 0 && kids.every((k) => next[k.id] ?? !!k.checked);
774
+ next[pid] = allChecked;
775
+ pid = parentMap[pid];
776
+ }
777
+ return next;
778
+ });
779
+ },
780
+ };
781
+
782
+ return (
783
+ <div className="x-tree" style={style} ref={treeRef}>
784
+ <ul className={`x-tree-root-ct ${rootCls}`}>
785
+ <TreeBranchList nodes={topNodes} ctx={ctx} />
786
+ </ul>
787
+ {/* 拖拽落点指示:above/below 由 TreeBranch 给目标节点 el 挂原生 x-tree-drag-insert-above/below
788
+ 类(点状主题色边框,见 ext-all.css),append 由 x-tree-drag-append 类表达——均复用原生
789
+ 树拖拽落点机制,不写死颜色、不画浮动 div。 */}
790
+ {/* 拖影(复用 ext-all.css 的 .x-dd-drag-ghost) */}
791
+ {ghost && (
792
+ <div
793
+ className="x-dd-drag-ghost"
794
+ style={{
795
+ position: 'fixed',
796
+ left: ghost.x + 12,
797
+ top: ghost.y + 8,
798
+ pointerEvents: 'none',
799
+ zIndex: 15000,
800
+ }}
801
+ >
802
+ {ghost.text}
803
+ </div>
804
+ )}
805
+ </div>
806
+ );
807
+ });
808
+
809
+ TreePanel.displayName = 'TreePanel';