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,126 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ColorPaletteHandle {
|
|
4
|
+
/** 整体替换调色板颜色数组(6 位 hex,可带 #) */
|
|
5
|
+
setColors: (colors: string[]) => void;
|
|
6
|
+
/** 追加/插入一个颜色(index 省略则追加到末尾) */
|
|
7
|
+
addColor: (hex: string, index?: number) => void;
|
|
8
|
+
/** 删除指定颜色值(6 位 hex,可带 #) */
|
|
9
|
+
removeColor: (hex: string) => void;
|
|
10
|
+
/** 取当前颜色数组克隆 */
|
|
11
|
+
getColors: () => string[];
|
|
12
|
+
/** 选中指定颜色(等价 Ext select) */
|
|
13
|
+
select: (color: string) => void;
|
|
14
|
+
/** 取当前选中色(大写 6 位 hex,无选中返回 null) */
|
|
15
|
+
getSelected: () => string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Ext.ColorPalette 默认 40 色调色板,顺序与 Ext 源码 colors 数组完全一致,
|
|
19
|
+
// 以保证 golden 与 React 的 40 个 .color-XXXXXX 节点一一对应、像素一致。
|
|
20
|
+
export const COLORS = [
|
|
21
|
+
'000000', '993300', '333300', '003300', '003366', '000080', '333399', '333333',
|
|
22
|
+
'800000', 'FF6600', '808000', '008000', '008080', '0000FF', '666699', '808080',
|
|
23
|
+
'FF0000', 'FF9900', '99CC00', '339966', '33CCCC', '3366FF', '800080', '969696',
|
|
24
|
+
'FF00FF', 'FFCC00', 'FFFF00', '00FF00', '00FFFF', '00CCFF', '993366', 'C0C0C0',
|
|
25
|
+
'FF99CC', 'FFCC99', 'FFFF99', 'CCFFCC', 'CCFFFF', '99CCFF', 'CC99FF', 'FFFFFF',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface ColorPaletteProps {
|
|
29
|
+
/** 初始选中色(6 位 hex,可带或不带 #),默认无选中 */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** 调色板颜色数组(6 位 hex,可带 #);省略则用 Ext 默认 42 色 */
|
|
32
|
+
colors?: string[];
|
|
33
|
+
/** 选中回调,参数为大写 6 位 hex(不带 #) */
|
|
34
|
+
onSelect?: (color: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 保真映射(来源 Ext.ColorPalette.onRender 的 XTemplate):
|
|
38
|
+
// <tpl for="."><a href="#" class="color-{.}" hidefocus="on">
|
|
39
|
+
// <em><span style="background:#{.}" unselectable="on"> </span></em></a></tpl>
|
|
40
|
+
// - 根 div class = 'x-color-palette'(itemCls,无额外 baseCls)。
|
|
41
|
+
// - 每色一个 <a class="color-{HEX}">,内部 <em><span style="background:#{HEX}">。
|
|
42
|
+
// - 选中:afterRender 调 select(value),给对应 <a> 追加 'x-color-palette-sel'。
|
|
43
|
+
// 视觉由 CSS 驱动(a.x-color-palette-sel 设 border-color:#8bb8f3; background:#deecfd),无需额外 DOM。
|
|
44
|
+
// - 全部样式来自 ext-all.css(11 条 .x-color-palette* 规则),零新增 CSS 契约破坏。
|
|
45
|
+
const norm = (v?: string) => (v || '').replace('#', '').toUpperCase();
|
|
46
|
+
|
|
47
|
+
export const ColorPalette = forwardRef<ColorPaletteHandle, ColorPaletteProps>(
|
|
48
|
+
({ value, colors, onSelect }, ref) => {
|
|
49
|
+
// 单源数组法:dynColors 为唯一真相,句柄运行时改写;useEffect([colors]) 同步 prop 变化。
|
|
50
|
+
const [dynColors, setDynColors] = useState<string[]>(() => colors ?? COLORS);
|
|
51
|
+
const colorsRef = useRef<string[]>(dynColors);
|
|
52
|
+
const [sel, setSel] = useState<string | null>(norm(value) || null);
|
|
53
|
+
const selRef = useRef<string | null>(sel);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
colorsRef.current = dynColors;
|
|
56
|
+
}, [dynColors]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
selRef.current = sel;
|
|
59
|
+
}, [sel]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (colors) setDynColors(colors);
|
|
62
|
+
}, [colors]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (value !== undefined) setSel(norm(value) || null);
|
|
65
|
+
}, [value]);
|
|
66
|
+
|
|
67
|
+
useImperativeHandle(
|
|
68
|
+
ref,
|
|
69
|
+
(): ColorPaletteHandle => ({
|
|
70
|
+
setColors: (next) => setDynColors([...next]),
|
|
71
|
+
addColor: (hex, index) => {
|
|
72
|
+
setDynColors((prev) => {
|
|
73
|
+
const next = [...prev];
|
|
74
|
+
if (index == null || index >= next.length) next.push(hex);
|
|
75
|
+
else next.splice(index, 0, hex);
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
removeColor: (hex) => {
|
|
80
|
+
const up = norm(hex);
|
|
81
|
+
setDynColors((prev) => prev.filter((c) => norm(c) !== up));
|
|
82
|
+
},
|
|
83
|
+
getColors: () => colorsRef.current.map((c) => c),
|
|
84
|
+
select: (color) => {
|
|
85
|
+
const up = norm(color);
|
|
86
|
+
setSel(up);
|
|
87
|
+
onSelect?.(up);
|
|
88
|
+
selRef.current = up;
|
|
89
|
+
},
|
|
90
|
+
getSelected: () => selRef.current,
|
|
91
|
+
}),
|
|
92
|
+
[onSelect],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const select = (hex: string) => {
|
|
96
|
+
const up = hex.toUpperCase();
|
|
97
|
+
setSel(up);
|
|
98
|
+
onSelect?.(up);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="x-color-palette">
|
|
103
|
+
{dynColors.map((c) => (
|
|
104
|
+
<a
|
|
105
|
+
key={c}
|
|
106
|
+
className={['color-' + c, sel === c.toUpperCase() ? 'x-color-palette-sel' : '']
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(' ')}
|
|
109
|
+
href="#"
|
|
110
|
+
onClick={(e) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
select(c);
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<em>
|
|
116
|
+
<span style={{ background: `#${c}` }} unselectable="on">
|
|
117
|
+
{' '}
|
|
118
|
+
</span>
|
|
119
|
+
</em>
|
|
120
|
+
</a>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ColorPalette.displayName = 'ColorPalette';
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useLayoutEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useMemo,
|
|
6
|
+
type FC,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { Panel } from './Panel';
|
|
11
|
+
|
|
12
|
+
export interface ColumnItem {
|
|
13
|
+
/** 列标题(渲染 x-panel-header) */
|
|
14
|
+
title?: string;
|
|
15
|
+
/** 列主体内容(纯文本/HTML 字符串);也可用 children 渲染任意 React 节点 */
|
|
16
|
+
html?: string;
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
/** 固定列宽(px)。与 columnWidth 二选一;给定 width 即不被列宽算法拉伸。 */
|
|
19
|
+
width?: number;
|
|
20
|
+
/** 比例列宽(0~1)。多个 columnWidth 按各自比例分配“剩余可用宽”(总宽 − 所有固定列宽)。 */
|
|
21
|
+
columnWidth?: number;
|
|
22
|
+
/** 列外边距简写 "t r b l"(同 CSS)。影响列宽分配与渲染。默认 0。 */
|
|
23
|
+
margins?: string;
|
|
24
|
+
/** 透传到 x-panel-body 的 inline style */
|
|
25
|
+
bodyStyle?: CSSProperties;
|
|
26
|
+
/** 透传到列根元素的附加 class(追加在 .x-column 之后) */
|
|
27
|
+
cls?: string;
|
|
28
|
+
/** 透传到列根元素的内联样式(裸列 / 覆盖视觉用) */
|
|
29
|
+
columnStyle?: CSSProperties;
|
|
30
|
+
/** 透传到列根元素的 data-id(供拖拽等场景定位列) */
|
|
31
|
+
dataId?: string;
|
|
32
|
+
/** 裸列渲染内容(供 Portal 等无需 Panel 包装的场景)。提供 node 时组件只算宽 + float,
|
|
33
|
+
* 不再包 .x-panel;视觉完全由调用方在 node 内提供(保持中性 / 借原生类名,不硬编码主题色)。 */
|
|
34
|
+
node?: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ColumnLayoutProps {
|
|
38
|
+
/** 容器总宽(px),对齐 Ext.Container width。不传或为 0 时自动测量内容区宽(响应式)。 */
|
|
39
|
+
width?: number;
|
|
40
|
+
items: ColumnItem[];
|
|
41
|
+
/** 数据测试 id,挂在根 .x-column-layout-ct 上 */
|
|
42
|
+
testId?: string;
|
|
43
|
+
style?: CSSProperties;
|
|
44
|
+
className?: string;
|
|
45
|
+
/** 子项默认外边距简写。默认 '0'。 */
|
|
46
|
+
defaultMargins?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Margins = { top: number; right: number; bottom: number; left: number };
|
|
50
|
+
|
|
51
|
+
const parseMargins = (v?: string): Margins => {
|
|
52
|
+
const p = (v || '0').trim().split(/\s+/).map(Number);
|
|
53
|
+
const top = p[0] ?? 0;
|
|
54
|
+
const right = p[1] != null ? p[1] : p[0] ?? 0;
|
|
55
|
+
const bottom = p[2] != null ? p[2] : p[0] ?? 0;
|
|
56
|
+
const left = p[3] != null ? p[3] : p[1] != null ? p[1] : p[0] ?? 0;
|
|
57
|
+
return { top, right, bottom, left };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 复刻 Ext.layout.ColumnLayout(Ext 3.2.1)。
|
|
62
|
+
*
|
|
63
|
+
* DOM 对齐 ColumnLayout.js 真值:
|
|
64
|
+
* - 容器根 .x-column-layout-ct;
|
|
65
|
+
* - renderAll 创建 .x-column-inner(宽=容器宽);
|
|
66
|
+
* - 每个子面板元素追加 extraCls='x-column'(float:left,见 ext-all.css:4757);
|
|
67
|
+
* 内联 display:block + width(Ext setSize 设宽)。
|
|
68
|
+
*
|
|
69
|
+
* 宽分配算法逐行移植 onLayout(ext-all-debug.js:12637):
|
|
70
|
+
* - 先遍历非 columnWidth 项:pw -= (width + 左右 margin);
|
|
71
|
+
* - 再遍历 columnWidth 项:width = floor(columnWidth * pw) - 左右 margin。
|
|
72
|
+
*
|
|
73
|
+
* 铁律对齐(零硬编码主题色):
|
|
74
|
+
* - 列边框不再硬编码蓝(移除原 #99bbe8 单线描边);Panel 列回归原生 .x-panel 边框
|
|
75
|
+
* (蓝/灰随 xtheme 主题切换),裸列(node 模式)视觉完全由调用方提供(中性或借原生类)。
|
|
76
|
+
* - 响应式增强:width 未显式传入时,以 useLayoutEffect + ResizeObserver 测量
|
|
77
|
+
* .x-column-inner 内容宽作为 pw 起点,使 Portal 等响应式场景无需硬写容器宽。
|
|
78
|
+
*/
|
|
79
|
+
export const ColumnLayout: FC<ColumnLayoutProps> = (props) => {
|
|
80
|
+
const {
|
|
81
|
+
width: propWidth,
|
|
82
|
+
items,
|
|
83
|
+
testId,
|
|
84
|
+
style,
|
|
85
|
+
className = '',
|
|
86
|
+
defaultMargins = '0',
|
|
87
|
+
} = props;
|
|
88
|
+
|
|
89
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
90
|
+
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
91
|
+
const [measured, setMeasured] = useState(0);
|
|
92
|
+
|
|
93
|
+
// 响应式测量容器内容宽(仅当未显式传 width 时使用),对齐原生 getLayoutTargetSize。
|
|
94
|
+
// 必须测量根 .x-column-layout-ct:其宽度由父容器决定、不依赖 availW;若测 .x-column-inner
|
|
95
|
+
// (其 width 绑定 availW,首帧为 0)会形成「测到 0 → 保持 0」死循环,导致所有列宽塌成 0。
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const el = rootRef.current;
|
|
98
|
+
if (!el) return;
|
|
99
|
+
const measure = () => setMeasured(el.clientWidth);
|
|
100
|
+
measure();
|
|
101
|
+
const ro = new ResizeObserver(measure);
|
|
102
|
+
ro.observe(el);
|
|
103
|
+
return () => ro.disconnect();
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const defMar = parseMargins(defaultMargins);
|
|
107
|
+
// 显式 width 优先(兼容既有 demo / 测试),否则用测量值(响应式)
|
|
108
|
+
const availW = propWidth && propWidth > 0 ? propWidth : measured;
|
|
109
|
+
|
|
110
|
+
const { widths, marginList } = useMemo(() => {
|
|
111
|
+
const mList = items.map((it) => (it.margins ? parseMargins(it.margins) : defMar));
|
|
112
|
+
let pw = availW;
|
|
113
|
+
items.forEach((it, i) => {
|
|
114
|
+
if (it.columnWidth == null) {
|
|
115
|
+
pw -= (it.width ?? 0) + mList[i].left + mList[i].right;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
pw = pw < 0 ? 0 : pw;
|
|
119
|
+
const w = items.map((it, i) => {
|
|
120
|
+
const m = mList[i];
|
|
121
|
+
if (it.columnWidth != null) {
|
|
122
|
+
return Math.floor(it.columnWidth * pw) - m.left - m.right;
|
|
123
|
+
}
|
|
124
|
+
return it.width ?? 0;
|
|
125
|
+
});
|
|
126
|
+
return { widths: w, marginList: mList };
|
|
127
|
+
}, [items, availW, defMar]);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
ref={rootRef}
|
|
132
|
+
data-testid={testId}
|
|
133
|
+
className={['x-column-layout-ct', className].filter(Boolean).join(' ')}
|
|
134
|
+
style={style}
|
|
135
|
+
>
|
|
136
|
+
<div className="x-column-inner" ref={innerRef} style={{ width: availW }}>
|
|
137
|
+
{items.map((it, i) => {
|
|
138
|
+
const m = marginList[i];
|
|
139
|
+
const margStyle: CSSProperties =
|
|
140
|
+
m.top || m.right || m.bottom || m.left
|
|
141
|
+
? { margin: `${m.top}px ${m.right}px ${m.bottom}px ${m.left}px` }
|
|
142
|
+
: {};
|
|
143
|
+
|
|
144
|
+
// —— 裸列模式:node 直接作为列内容,组件只负责算宽 + float;视觉外置 ——
|
|
145
|
+
if (it.node != null) {
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
key={it.dataId ?? i}
|
|
149
|
+
className={['x-column', it.cls].filter(Boolean).join(' ')}
|
|
150
|
+
data-dd-column
|
|
151
|
+
data-id={it.dataId}
|
|
152
|
+
style={{
|
|
153
|
+
float: 'left',
|
|
154
|
+
width: widths[i],
|
|
155
|
+
...margStyle,
|
|
156
|
+
...it.columnStyle,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{it.node}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// —— Panel 列模式:列即 .x-panel,边框回退到原生 .x-panel(蓝/灰随 xtheme 主题切换),
|
|
165
|
+
// 不硬编码任何主题色。box-sizing:border-box 让 1px 边框画在 width 内侧,
|
|
166
|
+
// 各列宽精确相加得到容器宽、不溢出。 ——
|
|
167
|
+
return (
|
|
168
|
+
<Panel
|
|
169
|
+
key={i}
|
|
170
|
+
title={it.title}
|
|
171
|
+
cls={['x-column', it.cls].filter(Boolean).join(' ')}
|
|
172
|
+
width={widths[i]}
|
|
173
|
+
style={{ display: 'block', boxSizing: 'border-box', ...margStyle }}
|
|
174
|
+
bodyStyle={it.bodyStyle}
|
|
175
|
+
>
|
|
176
|
+
{it.html != null ? it.html : it.children}
|
|
177
|
+
</Panel>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ext.ux.tree.ColumnTree / ColumnNodeUI 保真复刻(来源 examples/tree/column-tree.js、
|
|
3
|
+
* examples/ux/ColumnNodeUI.js、examples/ux/css/ColumnNodeUI.css)。
|
|
4
|
+
*
|
|
5
|
+
* 保真契约(DOM/class 严格对齐 Ext 3.2.1):
|
|
6
|
+
* - 面板根:div.x-panel.x-column-tree(+ 可选 cls),与 Ext Panel 同构。
|
|
7
|
+
* - 面板头:div.x-panel-header
|
|
8
|
+
* > span.x-panel-header-text(标题)
|
|
9
|
+
* > div.x-tree-headers(列头行,width = 列总宽)
|
|
10
|
+
* > div.x-tree-hd(.{c.cls}-hd) > div.x-tree-hd-text(每列一个)
|
|
11
|
+
* > div.x-clear
|
|
12
|
+
* - 面板体:div.x-panel-body(height 给定时 overflow:auto)
|
|
13
|
+
* > div.x-tree.x-tree-no-lines(width = 列总宽)> ul.x-tree-root-ct
|
|
14
|
+
* > li.x-tree-node(每个节点)
|
|
15
|
+
* > div.x-tree-node-el(选中加 x-tree-selected;整行着色由 CSS 承担)
|
|
16
|
+
* > div.x-tree-col(首列:缩进 + ec 图标 + 节点图标 + anchor + 文本)
|
|
17
|
+
* > div.x-tree-col(其余列:x-tree-col-text)
|
|
18
|
+
* > div.x-clear
|
|
19
|
+
* > ul.x-tree-node-ct(子节点;展开 block / 折叠 none)
|
|
20
|
+
* - 列宽:每列 col / hd 的 outer 宽 = c.width(本项目全局 border-box,边框已含在 width 内,
|
|
21
|
+
* 与 Ext 在 isBorderBox 时 width = c.width 等价);header 与数据列同宽,总宽与列模型一致。
|
|
22
|
+
*
|
|
23
|
+
* 行为契约:
|
|
24
|
+
* - 展开/折叠:点击 ec-icon 切换 expanded(翻转 el 的 expanded/collapsed、ec 图标的 plus/minus、ct 的 display)。
|
|
25
|
+
* - 选中:点击节点 → 加 x-tree-selected(同级唯一),移除兄弟选中。
|
|
26
|
+
* - 悬停:onMouseEnter 加 x-tree-node-over,onMouseLeave 移除。
|
|
27
|
+
* - 首列值:node.text 优先,否则 c.renderer 或 node[c.dataIndex];其余列:c.renderer 或 node[c.dataIndex]。
|
|
28
|
+
*/
|
|
29
|
+
import React, { useState, useRef, useImperativeHandle, forwardRef, type CSSProperties } from 'react';
|
|
30
|
+
|
|
31
|
+
// 与原版 Ext.BLANK_IMAGE_URL 等价(1x1 透明 gif),避免网络请求
|
|
32
|
+
const BLANK =
|
|
33
|
+
'data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
|
|
34
|
+
|
|
35
|
+
export interface ColumnTreeColumn {
|
|
36
|
+
/** 列头文本 */
|
|
37
|
+
header: string;
|
|
38
|
+
/** 数据字段名(首列缺省取 node.text,其余列取 node[dataIndex]) */
|
|
39
|
+
dataIndex: string;
|
|
40
|
+
/** 列宽(px) */
|
|
41
|
+
width: number;
|
|
42
|
+
/** 附加在列头 div 上的 class(渲染为 {cls}-hd) */
|
|
43
|
+
cls?: string;
|
|
44
|
+
/** 单元格渲染器:(value, node, node) => ReactNode,覆盖默认文本 */
|
|
45
|
+
renderer?: (value: unknown, node: ColumnTreeNode, attrs: ColumnTreeNode) => React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ColumnTreeNode {
|
|
49
|
+
/** 节点唯一 id(必填,供 key / 选中 / 命令式句柄定位) */
|
|
50
|
+
id: string;
|
|
51
|
+
/** 首列默认文本(缺省回退到首列 dataIndex 字段) */
|
|
52
|
+
text?: string;
|
|
53
|
+
/** 初始是否展开 */
|
|
54
|
+
expanded?: boolean;
|
|
55
|
+
/** 强制显示 +/- 展开柄(即使无子节点) */
|
|
56
|
+
expandable?: boolean;
|
|
57
|
+
/** 显式叶子(不显示展开柄) */
|
|
58
|
+
leaf?: boolean;
|
|
59
|
+
/** 初始是否禁用 */
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/** 图标图片路径(覆盖默认空白图标) */
|
|
62
|
+
icon?: string;
|
|
63
|
+
/** 图标 class(加到 x-tree-node-icon 上,由 CSS 提供背景图) */
|
|
64
|
+
iconCls?: string;
|
|
65
|
+
/** 附加 class(落在 x-tree-node-el 上) */
|
|
66
|
+
cls?: string;
|
|
67
|
+
/** 子节点 */
|
|
68
|
+
children?: ColumnTreeNode[];
|
|
69
|
+
/** 其余列数据字段 */
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ColumnTreeProps {
|
|
74
|
+
/** 面板标题(落在 x-panel-header-text) */
|
|
75
|
+
title?: string;
|
|
76
|
+
/** 面板宽度(px) */
|
|
77
|
+
width?: number;
|
|
78
|
+
/** 面板高度(px),设置后启用体滚动区 */
|
|
79
|
+
height?: number;
|
|
80
|
+
/** 列模型(必填) */
|
|
81
|
+
columns: ColumnTreeColumn[];
|
|
82
|
+
/** 顶层节点数组 */
|
|
83
|
+
nodes?: ColumnTreeNode[];
|
|
84
|
+
/** 根节点(与 nodes 二选一;rootVisible:false 时仅用其 children) */
|
|
85
|
+
root?: ColumnTreeNode;
|
|
86
|
+
/** 是否显示根节点,默认 false(对齐 Ext ColumnTree 示例 rootVisible:false) */
|
|
87
|
+
rootVisible?: boolean;
|
|
88
|
+
/** 是否显示连接线(ColumnTree 默认 false → x-tree-no-lines) */
|
|
89
|
+
lines?: boolean;
|
|
90
|
+
/** 启用体滚动区(对齐 Ext autoScroll) */
|
|
91
|
+
autoScroll?: boolean;
|
|
92
|
+
/** 追加在面板根上的 class */
|
|
93
|
+
cls?: string;
|
|
94
|
+
/** 选中节点回调(参数为节点对象) */
|
|
95
|
+
onSelect?: (node: ColumnTreeNode) => void;
|
|
96
|
+
/** 展开/折叠回调 */
|
|
97
|
+
onExpand?: (node: ColumnTreeNode, expanded: boolean) => void;
|
|
98
|
+
/** 透传到 .x-panel-body 的 inline style */
|
|
99
|
+
bodyStyle?: CSSProperties;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ColumnTreeHandle {
|
|
103
|
+
/** 读取当前根层节点数组(实时快照) */
|
|
104
|
+
getNodes: () => ColumnTreeNode[];
|
|
105
|
+
/** 展开节点 */
|
|
106
|
+
expand: (id: string) => void;
|
|
107
|
+
/** 折叠节点 */
|
|
108
|
+
collapse: (id: string) => void;
|
|
109
|
+
/** 切换展开/折叠 */
|
|
110
|
+
toggle: (id: string) => void;
|
|
111
|
+
/** 选中节点 */
|
|
112
|
+
select: (id: string) => void;
|
|
113
|
+
/** 当前选中节点 id(无选中返回 null) */
|
|
114
|
+
getSelected: () => string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 盒模型对齐:本项目全局 box-sizing:border-box(Ext 3 默认 content-box)。
|
|
118
|
+
// border-box 下列 outer 宽即配置 width(边框已含在 width 内),故 ΔborderWidth = 0,
|
|
119
|
+
// 与 Ext 在 isBorderBox 时为列设置 width = c.width 等价。
|
|
120
|
+
const BORDER_WIDTH = 0;
|
|
121
|
+
|
|
122
|
+
interface RenderCtx {
|
|
123
|
+
columns: ColumnTreeColumn[];
|
|
124
|
+
expandedIds: Record<string, boolean>;
|
|
125
|
+
selectedId: string | null;
|
|
126
|
+
ancestorsLast: boolean[];
|
|
127
|
+
onSelect: (node: ColumnTreeNode) => void;
|
|
128
|
+
onToggle: (id: string, node: ColumnTreeNode) => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ColumnBranch({
|
|
132
|
+
node,
|
|
133
|
+
isLast,
|
|
134
|
+
ctx,
|
|
135
|
+
}: {
|
|
136
|
+
node: ColumnTreeNode;
|
|
137
|
+
isLast: boolean;
|
|
138
|
+
ctx: RenderCtx;
|
|
139
|
+
}) {
|
|
140
|
+
const [hovered, setHovered] = useState(false);
|
|
141
|
+
|
|
142
|
+
const children = node.children;
|
|
143
|
+
const hasChildren = !!children && children.length > 0;
|
|
144
|
+
const expandable = (hasChildren || node.expandable === true) && node.leaf !== true;
|
|
145
|
+
const expanded = !!ctx.expandedIds[node.id];
|
|
146
|
+
const selected = ctx.selectedId === node.id;
|
|
147
|
+
const disabled = !!node.disabled;
|
|
148
|
+
|
|
149
|
+
// el 状态 class
|
|
150
|
+
const elCls = [
|
|
151
|
+
'x-tree-node-el',
|
|
152
|
+
expandable ? (expanded ? 'x-tree-node-expanded' : 'x-tree-node-collapsed') : 'x-tree-node-leaf',
|
|
153
|
+
hovered && !disabled ? 'x-tree-node-over' : '',
|
|
154
|
+
selected ? 'x-tree-selected' : '',
|
|
155
|
+
disabled ? 'x-tree-node-disabled' : '',
|
|
156
|
+
'x-unselectable',
|
|
157
|
+
node.cls || '',
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join(' ');
|
|
161
|
+
|
|
162
|
+
// ec-icon:自身末位 → x-tree-elbow-end,否则 x-tree-elbow;可展开加 -plus/-minus
|
|
163
|
+
const ecBase = isLast ? 'x-tree-elbow-end' : 'x-tree-elbow';
|
|
164
|
+
const ecSuffix = expandable ? (expanded ? '-minus' : '-plus') : '';
|
|
165
|
+
const ecCls = ['x-tree-ec-icon', `${ecBase}${ecSuffix}`].join(' ');
|
|
166
|
+
|
|
167
|
+
const iconCls = [
|
|
168
|
+
'x-tree-node-icon',
|
|
169
|
+
node.icon ? 'x-tree-node-inline-icon' : '',
|
|
170
|
+
node.iconCls || '',
|
|
171
|
+
]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(' ');
|
|
174
|
+
|
|
175
|
+
// 祖先缩进:每个祖先非末位 → x-tree-elbow-line,末位 → x-tree-icon
|
|
176
|
+
const indentImgs = ctx.ancestorsLast.map((aLast) =>
|
|
177
|
+
aLast ? 'x-tree-icon' : 'x-tree-elbow-line',
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const handleSelect = () => {
|
|
181
|
+
if (disabled) return;
|
|
182
|
+
ctx.onSelect(node);
|
|
183
|
+
};
|
|
184
|
+
const handleEcClick = (e: React.MouseEvent) => {
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
if (disabled || !expandable) return;
|
|
187
|
+
ctx.onToggle(node.id, node);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const firstCol = ctx.columns[0];
|
|
191
|
+
const firstValue =
|
|
192
|
+
node.text !== undefined
|
|
193
|
+
? node.text
|
|
194
|
+
: firstCol.renderer
|
|
195
|
+
? firstCol.renderer(node[firstCol.dataIndex], node, node)
|
|
196
|
+
: (node[firstCol.dataIndex] ?? '');
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<li className="x-tree-node">
|
|
200
|
+
<div
|
|
201
|
+
className={elCls}
|
|
202
|
+
unselectable="on"
|
|
203
|
+
data-node-id={node.id}
|
|
204
|
+
onMouseEnter={() => setHovered(true)}
|
|
205
|
+
onMouseLeave={() => setHovered(false)}
|
|
206
|
+
onClick={handleSelect}
|
|
207
|
+
>
|
|
208
|
+
{/* 首列:缩进 + ec 图标 + 节点图标 + anchor + 文本 */}
|
|
209
|
+
<div className="x-tree-col" style={{ width: firstCol.width - BORDER_WIDTH }}>
|
|
210
|
+
<span className="x-tree-node-indent">
|
|
211
|
+
{indentImgs.map((c, i) => (
|
|
212
|
+
<img key={i} src={BLANK} className={c} alt="" />
|
|
213
|
+
))}
|
|
214
|
+
</span>
|
|
215
|
+
<img src={BLANK} className={ecCls} alt="" onClick={handleEcClick} />
|
|
216
|
+
<img
|
|
217
|
+
src={node.icon || BLANK}
|
|
218
|
+
className={iconCls}
|
|
219
|
+
unselectable="on"
|
|
220
|
+
alt=""
|
|
221
|
+
/>
|
|
222
|
+
<a className="x-tree-node-anchor" href="#" tabIndex={1} onClick={(e) => e.preventDefault()}>
|
|
223
|
+
<span unselectable="on">{firstValue as React.ReactNode}</span>
|
|
224
|
+
</a>
|
|
225
|
+
</div>
|
|
226
|
+
{/* 其余列 */}
|
|
227
|
+
{ctx.columns.slice(1).map((c) => {
|
|
228
|
+
const value = c.renderer
|
|
229
|
+
? c.renderer(node[c.dataIndex], node, node)
|
|
230
|
+
: (node[c.dataIndex] ?? '');
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
key={c.dataIndex}
|
|
234
|
+
className={`x-tree-col ${c.cls || ''}`}
|
|
235
|
+
style={{ width: c.width - BORDER_WIDTH }}
|
|
236
|
+
>
|
|
237
|
+
<div className="x-tree-col-text">{value as React.ReactNode}</div>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
})}
|
|
241
|
+
<div className="x-clear" />
|
|
242
|
+
</div>
|
|
243
|
+
{(hasChildren || node.expandable === true) && (
|
|
244
|
+
<ul className="x-tree-node-ct" style={{ display: expanded ? 'block' : 'none' }}>
|
|
245
|
+
{hasChildren &&
|
|
246
|
+
children!.map((child, i) => (
|
|
247
|
+
<ColumnBranch
|
|
248
|
+
key={child.id}
|
|
249
|
+
node={child}
|
|
250
|
+
isLast={i === children!.length - 1}
|
|
251
|
+
ctx={{ ...ctx, ancestorsLast: [...ctx.ancestorsLast, isLast] }}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</ul>
|
|
255
|
+
)}
|
|
256
|
+
</li>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const ColumnTree = forwardRef<ColumnTreeHandle, ColumnTreeProps>(function ColumnTree(
|
|
261
|
+
{
|
|
262
|
+
title,
|
|
263
|
+
width,
|
|
264
|
+
height,
|
|
265
|
+
columns,
|
|
266
|
+
nodes,
|
|
267
|
+
root,
|
|
268
|
+
rootVisible = false,
|
|
269
|
+
lines = false,
|
|
270
|
+
autoScroll = true,
|
|
271
|
+
cls = '',
|
|
272
|
+
onSelect,
|
|
273
|
+
onExpand,
|
|
274
|
+
bodyStyle,
|
|
275
|
+
},
|
|
276
|
+
ref,
|
|
277
|
+
) {
|
|
278
|
+
const topNodes = React.useMemo<ColumnTreeNode[]>(() => {
|
|
279
|
+
if (nodes) return nodes;
|
|
280
|
+
if (root) return rootVisible ? [root] : root.children ?? [];
|
|
281
|
+
return [];
|
|
282
|
+
}, [nodes, root, rootVisible]);
|
|
283
|
+
|
|
284
|
+
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
|
|
285
|
+
const acc: Record<string, boolean> = {};
|
|
286
|
+
const walk = (ns: ColumnTreeNode[]) => {
|
|
287
|
+
ns.forEach((n) => {
|
|
288
|
+
if (n.expanded) acc[n.id] = true;
|
|
289
|
+
if (n.children) walk(n.children);
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
walk(topNodes);
|
|
293
|
+
return acc;
|
|
294
|
+
});
|
|
295
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
296
|
+
const topNodesRef = useRef<ColumnTreeNode[]>(topNodes);
|
|
297
|
+
topNodesRef.current = topNodes;
|
|
298
|
+
|
|
299
|
+
useImperativeHandle(ref, () => ({
|
|
300
|
+
getNodes: () => topNodesRef.current,
|
|
301
|
+
expand: (id) => setExpandedIds((prev) => ({ ...prev, [id]: true })),
|
|
302
|
+
collapse: (id) => setExpandedIds((prev) => ({ ...prev, [id]: false })),
|
|
303
|
+
toggle: (id) =>
|
|
304
|
+
setExpandedIds((prev) => {
|
|
305
|
+
const next = { ...prev };
|
|
306
|
+
next[id] = !next[id];
|
|
307
|
+
return next;
|
|
308
|
+
}),
|
|
309
|
+
select: (id) => setSelectedId(id),
|
|
310
|
+
getSelected: () => selectedId,
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
const totalWidth = columns.reduce((s, c) => s + c.width, 0);
|
|
314
|
+
|
|
315
|
+
const ctx: RenderCtx = {
|
|
316
|
+
columns,
|
|
317
|
+
expandedIds,
|
|
318
|
+
selectedId,
|
|
319
|
+
ancestorsLast: [],
|
|
320
|
+
onSelect: (node) => {
|
|
321
|
+
setSelectedId(node.id);
|
|
322
|
+
onSelect?.(node);
|
|
323
|
+
},
|
|
324
|
+
onToggle: (id, node) => {
|
|
325
|
+
const willExpand = !expandedIds[id];
|
|
326
|
+
setExpandedIds((prev) => ({ ...prev, [id]: willExpand }));
|
|
327
|
+
onExpand?.(node, willExpand);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const showHeader = !!title;
|
|
332
|
+
const rootCls = lines ? 'x-tree-lines' : 'x-tree-no-lines';
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div
|
|
336
|
+
className={['x-panel', 'x-column-tree', cls].filter(Boolean).join(' ')}
|
|
337
|
+
style={{ width: width ?? totalWidth, height }}
|
|
338
|
+
>
|
|
339
|
+
{showHeader && (
|
|
340
|
+
<div className="x-panel-header" unselectable="on">
|
|
341
|
+
{title && <span className="x-panel-header-text">{title}</span>}
|
|
342
|
+
<div className="x-tree-headers" style={{ width: totalWidth }}>
|
|
343
|
+
{columns.map((c) => (
|
|
344
|
+
<div
|
|
345
|
+
key={c.dataIndex}
|
|
346
|
+
className={`x-tree-hd ${c.cls ? c.cls + '-hd' : ''}`}
|
|
347
|
+
style={{ width: c.width - BORDER_WIDTH }}
|
|
348
|
+
>
|
|
349
|
+
<div className="x-tree-hd-text">{c.header}</div>
|
|
350
|
+
</div>
|
|
351
|
+
))}
|
|
352
|
+
<div className="x-clear" />
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
<div
|
|
357
|
+
className="x-panel-body"
|
|
358
|
+
style={{
|
|
359
|
+
...bodyStyle,
|
|
360
|
+
overflowX: 'hidden',
|
|
361
|
+
overflowY: height != null && autoScroll ? 'auto' : undefined,
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
<div className={`x-tree ${rootCls}`} style={{ width: totalWidth }}>
|
|
365
|
+
<ul className={`x-tree-root-ct ${rootCls}`}>
|
|
366
|
+
{topNodes.map((n, i) => (
|
|
367
|
+
<ColumnBranch
|
|
368
|
+
key={n.id}
|
|
369
|
+
node={n}
|
|
370
|
+
isLast={i === topNodes.length - 1}
|
|
371
|
+
ctx={ctx}
|
|
372
|
+
/>
|
|
373
|
+
))}
|
|
374
|
+
</ul>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
ColumnTree.displayName = 'ColumnTree';
|
|
382
|
+
|
|
383
|
+
export default ColumnTree;
|