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.
- package/LICENSE +21 -0
- package/package.json +36 -0
- package/src/components/AbsoluteLayout.tsx +206 -0
- package/src/components/Accordion.tsx +266 -0
- package/src/components/AnchorLayout.tsx +131 -0
- package/src/components/BorderLayout.tsx +512 -0
- package/src/components/Box.tsx +289 -0
- package/src/components/Button.tsx +300 -0
- package/src/components/ButtonGroup.tsx +170 -0
- package/src/components/CalendarPicker.tsx +431 -0
- package/src/components/CardLayout.tsx +122 -0
- package/src/components/CenterLayout.tsx +95 -0
- package/src/components/CheckboxGroup.tsx +200 -0
- package/src/components/ColorMenu.tsx +195 -0
- package/src/components/ColorPalette.tsx +126 -0
- package/src/components/ColumnLayout.tsx +183 -0
- package/src/components/ColumnTree.tsx +383 -0
- package/src/components/CycleButton.tsx +302 -0
- package/src/components/DataView.tsx +244 -0
- package/src/components/DataViewMore.tsx +337 -0
- package/src/components/DataViewTransition.tsx +235 -0
- package/src/components/DateMenu.tsx +163 -0
- package/src/components/DatePicker.tsx +387 -0
- package/src/components/DisplayField.tsx +73 -0
- package/src/components/Editor.tsx +214 -0
- package/src/components/EditorGrid.tsx +361 -0
- package/src/components/FieldLabeler.tsx +81 -0
- package/src/components/FieldReplicator.tsx +135 -0
- package/src/components/FieldSet.tsx +149 -0
- package/src/components/FitLayout.tsx +76 -0
- package/src/components/Form.tsx +3484 -0
- package/src/components/Grid.tsx +2173 -0
- package/src/components/GridFilters.tsx +332 -0
- package/src/components/GroupTab.tsx +274 -0
- package/src/components/HtmlEditor.tsx +391 -0
- package/src/components/ImageOrganizer.tsx +984 -0
- package/src/components/ImageOrganizerService.ts +361 -0
- package/src/components/ListView.tsx +221 -0
- package/src/components/LoadMask.tsx +74 -0
- package/src/components/Menu.tsx +227 -0
- package/src/components/MessageBox.tsx +138 -0
- package/src/components/MultiSlider.tsx +369 -0
- package/src/components/Organizer.tsx +403 -0
- package/src/components/PagingToolbar.tsx +255 -0
- package/src/components/Panel.tsx +451 -0
- package/src/components/ProgressBar.tsx +127 -0
- package/src/components/PropertyGrid.tsx +142 -0
- package/src/components/QuickTip.tsx +174 -0
- package/src/components/Resizable.tsx +241 -0
- package/src/components/RowLayout.tsx +126 -0
- package/src/components/Slider.tsx +353 -0
- package/src/components/SliderField.tsx +206 -0
- package/src/components/SpinnerField.tsx +141 -0
- package/src/components/SplitBar.tsx +215 -0
- package/src/components/Spotlight.tsx +262 -0
- package/src/components/StatusBar.tsx +118 -0
- package/src/components/TabPanel.tsx +540 -0
- package/src/components/TableLayout.tsx +132 -0
- package/src/components/ThemeSwitcher.tsx +167 -0
- package/src/components/ToolTip.tsx +226 -0
- package/src/components/Toolbar.tsx +321 -0
- package/src/components/Tree.tsx +809 -0
- package/src/components/TreeGrid.tsx +418 -0
- package/src/components/Viewport.tsx +218 -0
- package/src/components/Window.tsx +441 -0
- package/src/components/Writer.tsx +303 -0
- package/src/components/XmlTreeLoader.tsx +233 -0
- package/src/components/dnd/DndContext.tsx +328 -0
- package/src/components/dnd/DragSource.tsx +202 -0
- package/src/components/dnd/DragZone.tsx +66 -0
- package/src/components/dnd/DropTarget.tsx +86 -0
- package/src/components/dnd/DropZone.tsx +166 -0
- package/src/components/dnd/PanelProxy.tsx +81 -0
- package/src/components/dnd/PanelResizer.tsx +143 -0
- package/src/components/dnd/Portal.tsx +254 -0
- package/src/components/dnd/Reorderer.tsx +73 -0
- package/src/components/dnd/ToolbarReorderer.tsx +226 -0
- package/src/main.tsx +2649 -0
- package/src/styles/ext-theme.css +889 -0
- package/src/util/theme.ts +95 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useEffect,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { Panel } from './Panel';
|
|
9
|
+
|
|
10
|
+
// 与原版 Ext.BLANK_IMAGE_URL 等价(Ext 3.2.1 默认 1x1 透明 gif)
|
|
11
|
+
const BLANK =
|
|
12
|
+
'data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
|
|
13
|
+
|
|
14
|
+
export interface TreeGridColumn {
|
|
15
|
+
header: string;
|
|
16
|
+
dataIndex: string;
|
|
17
|
+
width: number;
|
|
18
|
+
align?: 'left' | 'center' | 'right';
|
|
19
|
+
/** 附加在列 td 上的 class(可选) */
|
|
20
|
+
cls?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TreeGridNode {
|
|
24
|
+
id: string;
|
|
25
|
+
/** 初始是否展开(有子节点时生效) */
|
|
26
|
+
expanded?: boolean;
|
|
27
|
+
/** 即使无子节点也显示为可展开(collapsed plus 图标) */
|
|
28
|
+
expandable?: boolean;
|
|
29
|
+
children?: TreeGridNode[];
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TreeGridProps {
|
|
34
|
+
title?: string;
|
|
35
|
+
width?: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
/** 默认 false(对齐 Ext.ux.tree.TreeGrid 默认 rootVisible:false) */
|
|
38
|
+
rootVisible?: boolean;
|
|
39
|
+
/** 默认 true → 渲染 x-tree-arrows(对齐 Ext useArrows:true) */
|
|
40
|
+
useArrows?: boolean;
|
|
41
|
+
columns: TreeGridColumn[];
|
|
42
|
+
/** 顶层节点数组 */
|
|
43
|
+
data?: TreeGridNode[];
|
|
44
|
+
className?: string;
|
|
45
|
+
/** 选中节点回调(点击节点时触发,参数为节点 id);对齐 Ext TreeGrid 的 select 事件 */
|
|
46
|
+
onSelect?: (id: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 命令式句柄:运行时增删改节点 / 展开折叠 / 选中 */
|
|
50
|
+
export interface TreeGridHandle {
|
|
51
|
+
/** 整体替换数据源 */
|
|
52
|
+
setData: (data: TreeGridNode[]) => void;
|
|
53
|
+
/** 读取当前数据源(浅拷贝) */
|
|
54
|
+
getData: () => TreeGridNode[];
|
|
55
|
+
/** 在 parentId 下插入节点(null 表示顶层);index 缺省追加到末尾 */
|
|
56
|
+
addNode: (parentId: string | null, node: TreeGridNode, index?: number) => void;
|
|
57
|
+
/** 删除指定 id 的节点(含其子树) */
|
|
58
|
+
removeNode: (id: string) => void;
|
|
59
|
+
/** 局部更新节点属性(含展开/折叠/icon 等) */
|
|
60
|
+
updateNode: (id: string, patch: Partial<TreeGridNode>) => void;
|
|
61
|
+
/** 展开某节点 */
|
|
62
|
+
expand: (id: string) => void;
|
|
63
|
+
/** 折叠某节点 */
|
|
64
|
+
collapse: (id: string) => void;
|
|
65
|
+
/** 切换展开/折叠 */
|
|
66
|
+
toggle: (id: string) => void;
|
|
67
|
+
/** 选中某节点 */
|
|
68
|
+
select: (id: string) => void;
|
|
69
|
+
/** 当前选中节点 id */
|
|
70
|
+
getSelected: () => string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface NormNode extends TreeGridNode {
|
|
74
|
+
_parent: NormNode | RootSentinel | null;
|
|
75
|
+
_isLast: boolean;
|
|
76
|
+
_children: NormNode[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RootSentinel {
|
|
80
|
+
isRoot: true;
|
|
81
|
+
_isLast: boolean;
|
|
82
|
+
_parent: null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 规范化:建立 _parent 引用与是否末位兄弟,供缩进与 ec 图标计算。
|
|
86
|
+
function normalize(
|
|
87
|
+
nodes: TreeGridNode[],
|
|
88
|
+
parent: NormNode | RootSentinel | null,
|
|
89
|
+
rootVisible: boolean,
|
|
90
|
+
): NormNode[] {
|
|
91
|
+
return nodes.map((n, i) => {
|
|
92
|
+
const node: NormNode = {
|
|
93
|
+
...n,
|
|
94
|
+
_parent: parent,
|
|
95
|
+
_isLast: i === nodes.length - 1,
|
|
96
|
+
_children: [],
|
|
97
|
+
};
|
|
98
|
+
node._children = normalize(n.children || [], node, rootVisible);
|
|
99
|
+
return node;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 纯函数:在 parentId 下插入节点(null=顶层),index 缺省末尾追加
|
|
104
|
+
function insertNode(
|
|
105
|
+
nodes: TreeGridNode[],
|
|
106
|
+
parentId: string | null,
|
|
107
|
+
node: TreeGridNode,
|
|
108
|
+
index?: number,
|
|
109
|
+
): TreeGridNode[] {
|
|
110
|
+
if (parentId === null) {
|
|
111
|
+
const copy = [...nodes];
|
|
112
|
+
copy.splice(index ?? copy.length, 0, node);
|
|
113
|
+
return copy;
|
|
114
|
+
}
|
|
115
|
+
return nodes.map((n) => {
|
|
116
|
+
if (n.id === parentId) {
|
|
117
|
+
const children = [...(n.children || [])];
|
|
118
|
+
children.splice(index ?? children.length, 0, node);
|
|
119
|
+
return { ...n, children };
|
|
120
|
+
}
|
|
121
|
+
if (n.children) return { ...n, children: insertNode(n.children, parentId, node, index) };
|
|
122
|
+
return n;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 纯函数:删除指定 id 节点及其子树
|
|
127
|
+
function deleteNode(nodes: TreeGridNode[], id: string): TreeGridNode[] {
|
|
128
|
+
return nodes
|
|
129
|
+
.filter((n) => n.id !== id)
|
|
130
|
+
.map((n) => (n.children ? { ...n, children: deleteNode(n.children, id) } : n));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 纯函数:局部更新节点属性(递归匹配 id)
|
|
134
|
+
function patchNode(nodes: TreeGridNode[], id: string, patch: Partial<TreeGridNode>): TreeGridNode[] {
|
|
135
|
+
return nodes.map((n) => {
|
|
136
|
+
if (n.id === id) return { ...n, ...patch };
|
|
137
|
+
if (n.children) return { ...n, children: patchNode(n.children, id, patch) };
|
|
138
|
+
return n;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 对齐 Ext TreeNodeUI.getChildIndent:从节点自身向上遍历各祖先层级,
|
|
143
|
+
// 非末位兄弟 → x-tree-elbow-line,末位 → x-tree-icon;rootVisible:false 时跳过根。
|
|
144
|
+
// 注意:TreeGridNodeUI 用「父节点」的 getChildIndent 作为本节点的缩进,故此处入参为 node._parent。
|
|
145
|
+
function getChildIndent(parent: NormNode | RootSentinel | null, rootVisible: boolean): string[] {
|
|
146
|
+
const imgs: string[] = [];
|
|
147
|
+
let cur = parent;
|
|
148
|
+
while (cur) {
|
|
149
|
+
const isRoot = (cur as RootSentinel).isRoot === true;
|
|
150
|
+
if (!(isRoot && !rootVisible)) {
|
|
151
|
+
imgs.unshift((cur as NormNode)._isLast ? 'x-tree-icon' : 'x-tree-elbow-line');
|
|
152
|
+
}
|
|
153
|
+
cur = (cur as NormNode)._parent;
|
|
154
|
+
}
|
|
155
|
+
return imgs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 保真映射(来源 Ext.ux.tree.TreeGrid + TreeGridNodeUI,Ext 3.2.1 examples/ux):
|
|
160
|
+
* - 外层复用 Ext.Panel 结构(x-panel x-treegrid + x-panel-header + x-panel-body),body 内为
|
|
161
|
+
* .x-tree-root-ct.x-treegrid-ct.x-tree-arrows > .x-grid3-header(.x-treegrid-header-inner >
|
|
162
|
+
* .x-grid3-header-offset > table) + .x-treegrid-root-node > table.x-treegrid-root-table。
|
|
163
|
+
* - 每行 tbody.x-tree-node > tr.x-tree-node-el(.x-tree-node-expanded|collapsed|leaf) >
|
|
164
|
+
* td.x-treegrid-col(缩进+ec图标+节点图标+anchor) + 其余列 td.x-treegrid-col>div.x-treegrid-text;
|
|
165
|
+
* 随后 tr.x-tree-node-ct > td[colspan] > table.x-treegrid-node-ct-table(展开时含子节点)。
|
|
166
|
+
* - DOM 结构与 class 名与 Ext 真值完全一致;仅省略对像素无影响的命名空间属性 ext:tree-node-id
|
|
167
|
+
* (交互测试改用 data-id)。
|
|
168
|
+
*/
|
|
169
|
+
export const TreeGrid = forwardRef<TreeGridHandle, TreeGridProps>(function TreeGrid(
|
|
170
|
+
{
|
|
171
|
+
title,
|
|
172
|
+
width,
|
|
173
|
+
height,
|
|
174
|
+
rootVisible = false,
|
|
175
|
+
useArrows = true,
|
|
176
|
+
columns,
|
|
177
|
+
data = [],
|
|
178
|
+
className = '',
|
|
179
|
+
onSelect,
|
|
180
|
+
},
|
|
181
|
+
ref,
|
|
182
|
+
) {
|
|
183
|
+
// 双轨:data prop 作为初始值,句柄可运行时增删改
|
|
184
|
+
const [dynData, setDynData] = useState<TreeGridNode[]>(() => data);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
setDynData(data);
|
|
187
|
+
}, [data]);
|
|
188
|
+
|
|
189
|
+
const root = React.useMemo<RootSentinel>(() => ({ isRoot: true, _isLast: false, _parent: null }), []);
|
|
190
|
+
const norm = React.useMemo(() => normalize(dynData, root, rootVisible), [dynData, root, rootVisible]);
|
|
191
|
+
|
|
192
|
+
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
|
|
193
|
+
const s = new Set<string>();
|
|
194
|
+
const walk = (ns: NormNode[]) => {
|
|
195
|
+
ns.forEach((n) => {
|
|
196
|
+
if (n.expanded && (n.children?.length || n.expandable)) s.add(n.id);
|
|
197
|
+
walk(n._children);
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
walk(norm);
|
|
201
|
+
return s;
|
|
202
|
+
});
|
|
203
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
204
|
+
|
|
205
|
+
// 命令式句柄(运行时增删改 / 展开折叠 / 选中)
|
|
206
|
+
const setData = useCallback((d: TreeGridNode[]) => setDynData(d), []);
|
|
207
|
+
const getData = useCallback(() => dynData.map((x) => ({ ...x })), [dynData]);
|
|
208
|
+
const addNode = useCallback((parentId: string | null, node: TreeGridNode, index?: number) => {
|
|
209
|
+
setDynData((prev) => insertNode(prev, parentId, node, index));
|
|
210
|
+
if (node.expanded) setExpandedSet((prev) => new Set(prev).add(node.id));
|
|
211
|
+
}, []);
|
|
212
|
+
const removeNode = useCallback((id: string) => {
|
|
213
|
+
setDynData((prev) => deleteNode(prev, id));
|
|
214
|
+
setExpandedSet((prev) => {
|
|
215
|
+
const s = new Set(prev);
|
|
216
|
+
s.delete(id);
|
|
217
|
+
return s;
|
|
218
|
+
});
|
|
219
|
+
setSelectedId((prev) => (prev === id ? null : prev));
|
|
220
|
+
}, []);
|
|
221
|
+
const updateNode = useCallback((id: string, patch: Partial<TreeGridNode>) => {
|
|
222
|
+
setDynData((prev) => patchNode(prev, id, patch));
|
|
223
|
+
}, []);
|
|
224
|
+
const expand = useCallback((id: string) => setExpandedSet((prev) => new Set(prev).add(id)), []);
|
|
225
|
+
const collapse = useCallback((id: string) => {
|
|
226
|
+
setExpandedSet((prev) => {
|
|
227
|
+
const s = new Set(prev);
|
|
228
|
+
s.delete(id);
|
|
229
|
+
return s;
|
|
230
|
+
});
|
|
231
|
+
}, []);
|
|
232
|
+
const toggleNode = useCallback((id: string) => {
|
|
233
|
+
setExpandedSet((prev) => {
|
|
234
|
+
const s = new Set(prev);
|
|
235
|
+
if (s.has(id)) s.delete(id);
|
|
236
|
+
else s.add(id);
|
|
237
|
+
return s;
|
|
238
|
+
});
|
|
239
|
+
}, []);
|
|
240
|
+
const select = useCallback((id: string) => setSelectedId(id), []);
|
|
241
|
+
const getSelected = useCallback(() => selectedId, [selectedId]);
|
|
242
|
+
|
|
243
|
+
useImperativeHandle(
|
|
244
|
+
ref,
|
|
245
|
+
() => ({
|
|
246
|
+
setData,
|
|
247
|
+
getData,
|
|
248
|
+
addNode,
|
|
249
|
+
removeNode,
|
|
250
|
+
updateNode,
|
|
251
|
+
expand,
|
|
252
|
+
collapse,
|
|
253
|
+
toggle: toggleNode,
|
|
254
|
+
select,
|
|
255
|
+
getSelected,
|
|
256
|
+
}),
|
|
257
|
+
[setData, getData, addNode, removeNode, updateNode, expand, collapse, toggleNode, select, getSelected],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const total = columns.reduce((s, c) => s + c.width, 0);
|
|
261
|
+
const colCount = columns.length;
|
|
262
|
+
|
|
263
|
+
const toggle = (id: string) => {
|
|
264
|
+
setExpandedSet((prev) => {
|
|
265
|
+
const next = new Set(prev);
|
|
266
|
+
if (next.has(id)) next.delete(id);
|
|
267
|
+
else next.add(id);
|
|
268
|
+
return next;
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const colgroup = (
|
|
273
|
+
<colgroup>
|
|
274
|
+
{columns.map((c) => (
|
|
275
|
+
<col key={c.dataIndex} style={{ width: c.width }} />
|
|
276
|
+
))}
|
|
277
|
+
</colgroup>
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const renderNode = (node: NormNode): React.ReactElement => {
|
|
281
|
+
const expanded = expandedSet.has(node.id);
|
|
282
|
+
const hasChild = node._children.length > 0;
|
|
283
|
+
const expandable = !!node.expandable;
|
|
284
|
+
const isLast = node._isLast;
|
|
285
|
+
|
|
286
|
+
// ec 图标 class(对齐 Ext TreeNodeUI.updateExpandIcon)
|
|
287
|
+
const ecBase = isLast ? 'x-tree-elbow-end' : 'x-tree-elbow';
|
|
288
|
+
let ecClass = 'x-tree-ec-icon ' + ecBase;
|
|
289
|
+
if (hasChild || expandable) ecClass += expanded ? '-minus' : '-plus';
|
|
290
|
+
|
|
291
|
+
// 行 class
|
|
292
|
+
let rowCls: string;
|
|
293
|
+
if (hasChild || expandable) rowCls = expanded ? 'x-tree-node-expanded' : 'x-tree-node-collapsed';
|
|
294
|
+
else rowCls = 'x-tree-node-leaf';
|
|
295
|
+
|
|
296
|
+
const indentImgs = getChildIndent(node._parent, rootVisible).map((cls, i) => (
|
|
297
|
+
<img key={i} src={BLANK} className={cls} alt="" />
|
|
298
|
+
));
|
|
299
|
+
|
|
300
|
+
const firstValue = String(node[columns[0].dataIndex] ?? '');
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<tbody key={node.id} className="x-tree-node">
|
|
304
|
+
<tr
|
|
305
|
+
className={['x-tree-node-el', rowCls, selectedId === node.id ? 'x-tree-node-selected' : '']
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
.join(' ')}
|
|
308
|
+
data-id={node.id}
|
|
309
|
+
onClick={() => {
|
|
310
|
+
setSelectedId(node.id);
|
|
311
|
+
onSelect?.(node.id);
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
<td className="x-treegrid-col">
|
|
315
|
+
<span className="x-tree-node-indent">{indentImgs}</span>
|
|
316
|
+
<img
|
|
317
|
+
src={BLANK}
|
|
318
|
+
className={ecClass}
|
|
319
|
+
alt=""
|
|
320
|
+
onClick={(e) => {
|
|
321
|
+
e.stopPropagation();
|
|
322
|
+
toggle(node.id);
|
|
323
|
+
}}
|
|
324
|
+
/>
|
|
325
|
+
<img src={BLANK} className="x-tree-node-icon" unselectable="on" alt="" />
|
|
326
|
+
<a
|
|
327
|
+
className="x-tree-node-anchor"
|
|
328
|
+
href="#"
|
|
329
|
+
tabIndex={1}
|
|
330
|
+
onClick={(e) => e.preventDefault()}
|
|
331
|
+
>
|
|
332
|
+
<span unselectable="on">{firstValue}</span>
|
|
333
|
+
</a>
|
|
334
|
+
</td>
|
|
335
|
+
{columns.slice(1).map((c) => (
|
|
336
|
+
<td key={c.dataIndex} className={`x-treegrid-col ${c.cls || ''}`}>
|
|
337
|
+
<div
|
|
338
|
+
unselectable="on"
|
|
339
|
+
className="x-treegrid-text"
|
|
340
|
+
style={c.align ? { textAlign: c.align } : undefined}
|
|
341
|
+
>
|
|
342
|
+
{String(node[c.dataIndex] ?? '')}
|
|
343
|
+
</div>
|
|
344
|
+
</td>
|
|
345
|
+
))}
|
|
346
|
+
</tr>
|
|
347
|
+
<tr className="x-tree-node-ct">
|
|
348
|
+
<td colSpan={colCount}>
|
|
349
|
+
<table
|
|
350
|
+
className="x-treegrid-node-ct-table"
|
|
351
|
+
cellPadding={0}
|
|
352
|
+
cellSpacing={0}
|
|
353
|
+
style={{ tableLayout: 'fixed', display: expanded ? undefined : 'none', width: total }}
|
|
354
|
+
>
|
|
355
|
+
{colgroup}
|
|
356
|
+
{expanded ? node._children.map(renderNode) : null}
|
|
357
|
+
</table>
|
|
358
|
+
</td>
|
|
359
|
+
</tr>
|
|
360
|
+
</tbody>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const rootNodeHeight = height != null ? height - 50 : undefined; // 50 = 面板头 27 + grid 头 23
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<Panel title={title} width={width} cls={`x-treegrid ${className}`.trim()} height={height}>
|
|
368
|
+
<div
|
|
369
|
+
className={[
|
|
370
|
+
'x-tree-root-ct',
|
|
371
|
+
'x-treegrid-ct',
|
|
372
|
+
useArrows ? 'x-tree-arrows' : '',
|
|
373
|
+
]
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
.join(' ')}
|
|
376
|
+
>
|
|
377
|
+
<div className="x-grid3-header">
|
|
378
|
+
<div className="x-treegrid-header-inner">
|
|
379
|
+
<div className="x-grid3-header-offset" style={{ width: total }}>
|
|
380
|
+
<table cellSpacing={0} cellPadding={0} border={0} style={{ width: total }}>
|
|
381
|
+
{colgroup}
|
|
382
|
+
<thead>
|
|
383
|
+
<tr className="x-grid3-hd-row">
|
|
384
|
+
{columns.map((c) => (
|
|
385
|
+
<td
|
|
386
|
+
key={c.dataIndex}
|
|
387
|
+
className="x-grid3-hd x-grid3-cell x-treegrid-hd"
|
|
388
|
+
style={{ textAlign: c.align || 'left' }}
|
|
389
|
+
>
|
|
390
|
+
<div className="x-grid3-hd-inner x-treegrid-hd-inner" unselectable="on">
|
|
391
|
+
{c.header}
|
|
392
|
+
<img className="x-grid3-sort-icon" src={BLANK} alt="" />
|
|
393
|
+
</div>
|
|
394
|
+
</td>
|
|
395
|
+
))}
|
|
396
|
+
</tr>
|
|
397
|
+
</thead>
|
|
398
|
+
</table>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
<div className="x-treegrid-root-node" style={rootNodeHeight ? { height: rootNodeHeight } : undefined}>
|
|
403
|
+
<table
|
|
404
|
+
className="x-treegrid-root-table"
|
|
405
|
+
cellPadding={0}
|
|
406
|
+
cellSpacing={0}
|
|
407
|
+
style={{ width: total, tableLayout: 'fixed' }}
|
|
408
|
+
>
|
|
409
|
+
{colgroup}
|
|
410
|
+
{norm.map(renderNode)}
|
|
411
|
+
</table>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</Panel>
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
export default TreeGrid;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react';
|
|
2
|
+
import { Panel } from './Panel';
|
|
3
|
+
|
|
4
|
+
export type ViewportRegionName = 'north' | 'south' | 'east' | 'west' | 'center';
|
|
5
|
+
|
|
6
|
+
export interface ViewportRegionConfig {
|
|
7
|
+
region: ViewportRegionName;
|
|
8
|
+
/** east/west 区域宽度 */
|
|
9
|
+
width?: number;
|
|
10
|
+
/** north/south 区域高度 */
|
|
11
|
+
height?: number;
|
|
12
|
+
/** 区域面板标题(有标题则渲染头部,header 高 26px) */
|
|
13
|
+
title?: string;
|
|
14
|
+
/** body 内容(优先于 html) */
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
/** 纯文本/HTML 字符串内容(对齐 Ext region 的 html 配置) */
|
|
17
|
+
html?: string;
|
|
18
|
+
/** 追加到 body 的 inline style(会被布局计算的 width/height 合并) */
|
|
19
|
+
bodyStyle?: React.CSSProperties;
|
|
20
|
+
/** 是否显示边框(默认 true;false 时不挂 x-border-panel、保持无框) */
|
|
21
|
+
border?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ViewportProps {
|
|
25
|
+
/** 容器宽(外层 .x-viewport 宽度,无边框,内布局区 = width) */
|
|
26
|
+
width: number;
|
|
27
|
+
/** 容器高(内布局区 = height) */
|
|
28
|
+
height: number;
|
|
29
|
+
regions: ViewportRegionConfig[];
|
|
30
|
+
/** 挂在 .x-viewport 上的 testId(供保真截图/行为测试定位) */
|
|
31
|
+
testId?: string;
|
|
32
|
+
/** 追加到根 .x-viewport 的 class */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Viewport 命令式句柄:运行时增删/改 region、改容器尺寸。 */
|
|
37
|
+
export interface ViewportHandle {
|
|
38
|
+
/** 整体替换 region 配置数组 */
|
|
39
|
+
setRegions: (regions: ViewportRegionConfig[]) => void;
|
|
40
|
+
/** 读取当前 region 配置数组 */
|
|
41
|
+
getRegions: () => ViewportRegionConfig[];
|
|
42
|
+
/** 追加一个 region(若同名已存在则覆盖) */
|
|
43
|
+
addRegion: (cfg: ViewportRegionConfig) => void;
|
|
44
|
+
/** 移除某 region */
|
|
45
|
+
removeRegion: (region: ViewportRegionName) => void;
|
|
46
|
+
/** 局部更新某 region(合并 patch) */
|
|
47
|
+
updateRegion: (region: ViewportRegionName, patch: Partial<ViewportRegionConfig>) => void;
|
|
48
|
+
/** 调整某 region 的宽(east/west)或高(north/south) */
|
|
49
|
+
resizeRegion: (region: ViewportRegionName, size: number) => void;
|
|
50
|
+
/** 调整容器整体宽高 */
|
|
51
|
+
setSize: (width: number, height: number) => void;
|
|
52
|
+
/** 读取某 region 当前配置(不存在返回 undefined) */
|
|
53
|
+
getRegion: (region: ViewportRegionName) => ViewportRegionConfig | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 保真对齐 ExtJS 原生 border 布局(与仓库 BorderLayout 组件同源、与 golden 真值一致):
|
|
57
|
+
// 每个 region 渲染为「完整 1px 边框的 .x-panel.x-border-panel」,边框颜色完全交给 @import 进来的
|
|
58
|
+
// xtheme-*.css(blue #99bbe8 / gray #d0d0d0),此处不写死任何颜色、切换主题时整圈边框自动跟随。
|
|
59
|
+
// 相邻 region 直接相接时各自 1px 边框相接成 2px,这是原生默认五区演示的真实观感(非 bug);
|
|
60
|
+
// 外框即由最外层 region(north 顶 / south 底 / west 左 / east 右)的边框拼出,根 .x-viewport
|
|
61
|
+
// 本身无边框(对齐 golden:Ext.Container 默认 border:false,仅 region 面板提供框)。
|
|
62
|
+
// - 几何采用 box-sizing:border-box:1px 边框画在 box.w/box.h 内侧,不撑大所占矩形,
|
|
63
|
+
// 相邻 region 仍精确相贴、互不溢出/重叠(对齐 Ext region 被布局固定为 box 尺寸的行为)。
|
|
64
|
+
// - body 用 Panel 的 bodyFlex 模式(flex 纵向排列:header 固定、body flex:1 填满剩余),
|
|
65
|
+
// 不再依赖硬编码 header 高度,避免 header 实际高度 ≠ 常量时内容溢出覆盖相邻区域。
|
|
66
|
+
|
|
67
|
+
function findRegion(regions: ViewportRegionConfig[], r: ViewportRegionName): ViewportRegionConfig | undefined {
|
|
68
|
+
return regions.find((x) => x.region === r);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 保真映射(来源 src/widgets/layout/BorderLayout.js + Ext.Viewport):
|
|
73
|
+
* - Ext.Viewport 是 Container(layout:'border'),el 为 div.x-viewport,
|
|
74
|
+
* border 布局给其加 .x-border-layout-ct,区域直接作为 ct 的子节点绝对定位。
|
|
75
|
+
* - 区域渲染为 .x-panel.x-border-panel,带完整 1px 边框(border 默认 true),边框颜色由
|
|
76
|
+
* xtheme-*.css 主题化(blue #99bbe8 / gray #d0d0d0),此处零硬编码颜色。相邻 region 相接处
|
|
77
|
+
* 各自 1px 边框拼成 2px,是原生 border 布局的既定观感;外框由最外层 region 边框拼出,根
|
|
78
|
+
* .x-viewport 无边框(golden 的 Container border:false)。几何用 box-sizing:border-box,
|
|
79
|
+
* 边框画在 box 内侧,相邻 region 精确相贴、互不溢出。
|
|
80
|
+
* 定位数学(ct 无外层边框,ctW=width, ctH=height):
|
|
81
|
+
* north : left0 top0 w=ctW h=nH
|
|
82
|
+
* south : left0 top=ctH-sH w=ctW h=sH
|
|
83
|
+
* west : left0 top=nH w=wW h=ctH-nH-sH
|
|
84
|
+
* east : left=ctW-eW top=nH w=eW h=ctH-nH-sH
|
|
85
|
+
* center: left=wW top=nH w=ctW-wW-eW h=ctH-nH-sH
|
|
86
|
+
*/
|
|
87
|
+
export const Viewport = forwardRef<ViewportHandle, ViewportProps>(
|
|
88
|
+
({ width, height, regions, testId, className }, ref) => {
|
|
89
|
+
// 双轨:prop 作初始值,句柄可运行时改写
|
|
90
|
+
const [dynRegions, setDynRegions] = useState<ViewportRegionConfig[]>(() => regions);
|
|
91
|
+
const [dynWidth, setDynWidth] = useState<number>(() => width);
|
|
92
|
+
const [dynHeight, setDynHeight] = useState<number>(() => height);
|
|
93
|
+
|
|
94
|
+
const regionsRef = useRef(dynRegions);
|
|
95
|
+
const widthRef = useRef(dynWidth);
|
|
96
|
+
const heightRef = useRef(dynHeight);
|
|
97
|
+
regionsRef.current = dynRegions;
|
|
98
|
+
widthRef.current = dynWidth;
|
|
99
|
+
heightRef.current = dynHeight;
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
setDynRegions(regions);
|
|
103
|
+
}, [regions]);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
setDynWidth(width);
|
|
106
|
+
setDynHeight(height);
|
|
107
|
+
}, [width, height]);
|
|
108
|
+
|
|
109
|
+
const setRegions = useCallback((next: ViewportRegionConfig[]) => setDynRegions(next), []);
|
|
110
|
+
const getRegions = useCallback(() => regionsRef.current, []);
|
|
111
|
+
const addRegion = useCallback((cfg: ViewportRegionConfig) => {
|
|
112
|
+
setDynRegions((prev) => {
|
|
113
|
+
const others = prev.filter((r) => r.region !== cfg.region);
|
|
114
|
+
return [...others, cfg];
|
|
115
|
+
});
|
|
116
|
+
}, []);
|
|
117
|
+
const removeRegion = useCallback((region: ViewportRegionName) => {
|
|
118
|
+
setDynRegions((prev) => prev.filter((r) => r.region !== region));
|
|
119
|
+
}, []);
|
|
120
|
+
const updateRegion = useCallback(
|
|
121
|
+
(region: ViewportRegionName, patch: Partial<ViewportRegionConfig>) => {
|
|
122
|
+
setDynRegions((prev) => prev.map((r) => (r.region === region ? { ...r, ...patch } : r)));
|
|
123
|
+
},
|
|
124
|
+
[],
|
|
125
|
+
);
|
|
126
|
+
const resizeRegion = useCallback((region: ViewportRegionName, size: number) => {
|
|
127
|
+
setDynRegions((prev) =>
|
|
128
|
+
prev.map((r) => {
|
|
129
|
+
if (r.region !== region) return r;
|
|
130
|
+
if (region === 'east' || region === 'west') return { ...r, width: size };
|
|
131
|
+
return { ...r, height: size };
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}, []);
|
|
135
|
+
const setSize = useCallback((w: number, h: number) => {
|
|
136
|
+
setDynWidth(w);
|
|
137
|
+
setDynHeight(h);
|
|
138
|
+
}, []);
|
|
139
|
+
const getRegion = useCallback(
|
|
140
|
+
(region: ViewportRegionName) => findRegion(regionsRef.current, region),
|
|
141
|
+
[],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
useImperativeHandle(
|
|
145
|
+
ref,
|
|
146
|
+
() => ({
|
|
147
|
+
setRegions,
|
|
148
|
+
getRegions,
|
|
149
|
+
addRegion,
|
|
150
|
+
removeRegion,
|
|
151
|
+
updateRegion,
|
|
152
|
+
resizeRegion,
|
|
153
|
+
setSize,
|
|
154
|
+
getRegion,
|
|
155
|
+
}),
|
|
156
|
+
[setRegions, getRegions, addRegion, removeRegion, updateRegion, resizeRegion, setSize, getRegion],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const ctW = dynWidth;
|
|
160
|
+
const ctH = dynHeight;
|
|
161
|
+
|
|
162
|
+
const N = findRegion(dynRegions, 'north');
|
|
163
|
+
const S = findRegion(dynRegions, 'south');
|
|
164
|
+
const W = findRegion(dynRegions, 'west');
|
|
165
|
+
const E = findRegion(dynRegions, 'east');
|
|
166
|
+
const C = findRegion(dynRegions, 'center');
|
|
167
|
+
|
|
168
|
+
const nH = N?.height ?? 0;
|
|
169
|
+
const sH = S?.height ?? 0;
|
|
170
|
+
const wW = W?.width ?? 0;
|
|
171
|
+
const eW = E?.width ?? 0;
|
|
172
|
+
const midH = ctH - nH - sH;
|
|
173
|
+
|
|
174
|
+
const renderRegion = (cfg: ViewportRegionConfig | undefined, box: { w: number; h: number; left: number; top: number }) => {
|
|
175
|
+
if (!cfg) return null;
|
|
176
|
+
const hasHeader = !!cfg.title;
|
|
177
|
+
const content = cfg.children ?? (cfg.html !== undefined ? <span dangerouslySetInnerHTML={{ __html: cfg.html }} /> : null);
|
|
178
|
+
// 严格对齐原生 ExtJS border 布局(与仓库 BorderLayout 组件同源、与 golden 真值一致):
|
|
179
|
+
// 每个 region 渲染为「完整 1px 边框的 .x-panel.x-border-panel」,边框颜色完全交给 @import 进来的
|
|
180
|
+
// xtheme-*.css(blue #99bbe8 / gray #d0d0d0),此处不写死任何颜色、切换主题时整圈边框自动跟随。
|
|
181
|
+
// 相邻 region 直接相接时各自 1px 边框相接成 2px,这是原生默认五区演示的真实观感(非 bug);
|
|
182
|
+
// 外框即由最外层 region(north 顶 / south 底 / west 左 / east 右)的边框拼出,根 .x-viewport 无边框。
|
|
183
|
+
// box-sizing:border-box 使 1px 边框不撑破布局算出的 box.w/box.h。是否带框尊重 cfg.border(默认 true)。
|
|
184
|
+
const bordered = cfg.border !== false;
|
|
185
|
+
return (
|
|
186
|
+
<Panel
|
|
187
|
+
key={cfg.region}
|
|
188
|
+
cls={bordered ? 'x-border-panel' : ''}
|
|
189
|
+
width={box.w}
|
|
190
|
+
height={box.h}
|
|
191
|
+
header={hasHeader}
|
|
192
|
+
title={cfg.title}
|
|
193
|
+
border={bordered}
|
|
194
|
+
bodyFlex
|
|
195
|
+
style={{ position: 'absolute', left: box.left, top: box.top, boxSizing: 'border-box', borderRadius: 0, boxShadow: 'none' }}
|
|
196
|
+
bodyStyle={{ boxSizing: 'border-box', ...cfg.bodyStyle }}
|
|
197
|
+
>
|
|
198
|
+
{content}
|
|
199
|
+
</Panel>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
className={['x-viewport', 'x-border-layout-ct', className].filter(Boolean).join(' ')}
|
|
206
|
+
data-testid={testId}
|
|
207
|
+
style={{ position: 'relative', width: ctW, height: ctH }}
|
|
208
|
+
>
|
|
209
|
+
{renderRegion(N, { w: ctW, h: nH, left: 0, top: 0 })}
|
|
210
|
+
{renderRegion(S, { w: ctW, h: sH, left: 0, top: ctH - sH })}
|
|
211
|
+
{renderRegion(W, { w: wW, h: midH, left: 0, top: nH })}
|
|
212
|
+
{renderRegion(E, { w: eW, h: midH, left: ctW - eW, top: nH })}
|
|
213
|
+
{renderRegion(C, { w: ctW - wW - eW, h: midH, left: wW, top: nH })}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export default Viewport;
|