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,166 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import { DropTarget } from './DropTarget';
|
|
3
|
+
import { resolveThemeImageUrl } from '../../util/theme';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DropZone —— 保真复刻 Ext.dd.DropZone(含 DDTarget 行为)。
|
|
7
|
+
*
|
|
8
|
+
* DropZone 是一个「放置区」容器:拖拽源悬停时按指针位置计算插入索引(等价
|
|
9
|
+
* DropZone 的 getPosition / notifyOver 返回的落点),松手时把数据与算出的
|
|
10
|
+
* 插入索引交付给调用方(等价 onNodeDrop)。
|
|
11
|
+
*
|
|
12
|
+
* 落点反馈对齐原生 Ext.dd,由两部分组成:
|
|
13
|
+
* 1) 全局 StatusProxy 的状态图标(x-dd-drop-ok-add 加号 / x-dd-drop-nodrop 红叉,
|
|
14
|
+
* 随原生 dd.css 主题化)—— 由 DndManager 驱动,DropZone 在此声明 dropStatus。
|
|
15
|
+
* 2) 本组件渲染的「插入指示符」:一条实时跟随指针、落在待插入缝隙的落点指示线,
|
|
16
|
+
* 视觉直接复用 Ext 自带的「insert between」原生图案 drop-between.gif(原生资产,
|
|
17
|
+
* 不新增 CSS、不内联颜色、不硬编码主题色——与「零新增 CSS / 零硬编码色」铁律一致)。
|
|
18
|
+
* 原生 Ext.dd.DropZone 基类不画线,线由 TreeDropZone / GridDropZone 等子类用背景图
|
|
19
|
+
* 各自实现;本保真版沿用同一思路——用原生 gif 作插入指示,而非手写色块。
|
|
20
|
+
*
|
|
21
|
+
* 与 DragZone 配合即可实现可重排列表 / 跨列表移动(插入指示符 + 状态图标 + 松手时计算的
|
|
22
|
+
* 插入索引共同表达落点)。
|
|
23
|
+
*/
|
|
24
|
+
export interface DropZoneProps {
|
|
25
|
+
ddGroup?: string;
|
|
26
|
+
groups?: string[];
|
|
27
|
+
className?: string;
|
|
28
|
+
style?: React.CSSProperties;
|
|
29
|
+
/** 当前子项 id 顺序(用于插入位置语义;实际位置以 DOM 中 [data-dd-item] 为准) */
|
|
30
|
+
itemIds?: string[];
|
|
31
|
+
/** 悬停时插入索引变化回调(离开为 null),供调用方自行决定是否需要额外反馈 */
|
|
32
|
+
onInsertIndexChange?: (index: number | null) => void;
|
|
33
|
+
/** 有效放置:交付拖拽数据 + 计算出的插入索引 */
|
|
34
|
+
onDropItem?: (data: any, index: number) => void;
|
|
35
|
+
overClass?: string;
|
|
36
|
+
validate?: (data: any, e: PointerEvent) => boolean;
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 原生「insert between」落点指示图案(等价 Ext.dd.TreeDropZone 用的 drop-between.gif)。
|
|
41
|
+
* 按当前主题解析资源目录(blue/gray→default、access→access、brand→brand),使切主题时
|
|
42
|
+
* 落点图标跟随主题、不写死 default 蓝图(对齐原生 per-theme 资源机制)。 */
|
|
43
|
+
|
|
44
|
+
interface MarkerPos {
|
|
45
|
+
top: number;
|
|
46
|
+
left: number;
|
|
47
|
+
width: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 根据指针 Y 与 [data-dd-item] 中点比较,计算插入索引 */
|
|
51
|
+
function computeInsertIndex(container: HTMLElement, clientY: number): number {
|
|
52
|
+
const items = Array.from(
|
|
53
|
+
container.querySelectorAll<HTMLElement>('[data-dd-item]'),
|
|
54
|
+
);
|
|
55
|
+
for (let i = 0; i < items.length; i++) {
|
|
56
|
+
const r = items[i].getBoundingClientRect();
|
|
57
|
+
if (clientY < r.top + r.height / 2) return i;
|
|
58
|
+
}
|
|
59
|
+
return items.length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 在容器内相对坐标中,计算插入缝隙的指示符位置(顶/左/宽,单位 px) */
|
|
63
|
+
function computeMarker(container: HTMLElement, clientY: number): MarkerPos {
|
|
64
|
+
const rect = container.getBoundingClientRect();
|
|
65
|
+
const cs = getComputedStyle(container);
|
|
66
|
+
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
|
67
|
+
const br = parseFloat(cs.borderRightWidth) || 0;
|
|
68
|
+
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
69
|
+
const pl = parseFloat(cs.paddingLeft) || 0;
|
|
70
|
+
const pr = parseFloat(cs.paddingRight) || 0;
|
|
71
|
+
const pt = parseFloat(cs.paddingTop) || 0;
|
|
72
|
+
|
|
73
|
+
const items = Array.from(
|
|
74
|
+
container.querySelectorAll<HTMLElement>('[data-dd-item]'),
|
|
75
|
+
);
|
|
76
|
+
let idx = items.length;
|
|
77
|
+
for (let i = 0; i < items.length; i++) {
|
|
78
|
+
const r = items[i].getBoundingClientRect();
|
|
79
|
+
if (clientY < r.top + r.height / 2) {
|
|
80
|
+
idx = i;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const topOf = (el: HTMLElement) => el.getBoundingClientRect().top - rect.top - bt - pt;
|
|
86
|
+
const bottomOf = (el: HTMLElement) =>
|
|
87
|
+
el.getBoundingClientRect().bottom - rect.top - bt - pt;
|
|
88
|
+
|
|
89
|
+
let top: number;
|
|
90
|
+
if (items.length === 0) top = bt + pt;
|
|
91
|
+
else if (idx <= 0) top = topOf(items[0]);
|
|
92
|
+
else if (idx >= items.length) top = bottomOf(items[items.length - 1]);
|
|
93
|
+
else top = (bottomOf(items[idx - 1]) + topOf(items[idx])) / 2;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
top,
|
|
97
|
+
left: bl + pl,
|
|
98
|
+
width: rect.width - bl - br - pl - pr,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const DropZone: React.FC<DropZoneProps> = (props) => {
|
|
103
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
104
|
+
const groups = props.groups ?? (props.ddGroup ? [props.ddGroup] : ['default']);
|
|
105
|
+
const [marker, setMarker] = useState<MarkerPos | null>(null);
|
|
106
|
+
|
|
107
|
+
const handleOver = (data: any, e: PointerEvent) => {
|
|
108
|
+
const el = ref.current;
|
|
109
|
+
if (!el) return;
|
|
110
|
+
const idx = computeInsertIndex(el, e.clientY);
|
|
111
|
+
props.onInsertIndexChange?.(idx);
|
|
112
|
+
// 仅在允许放置时显示插入指示符(与 StatusProxy 状态图标保持一致)
|
|
113
|
+
const allowed = props.validate ? props.validate(data, e) : true;
|
|
114
|
+
setMarker(allowed ? computeMarker(el, e.clientY) : null);
|
|
115
|
+
};
|
|
116
|
+
const handleOut = () => {
|
|
117
|
+
props.onInsertIndexChange?.(null);
|
|
118
|
+
setMarker(null);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// 容器节点即放置区域(等价 Ext.dd.DropZone「整个 zone 元素都是放置目标」):
|
|
122
|
+
// 直接复用 DropTarget 作为根节点,避免多套一层无类名内层 div 使容器 padding 落在放置区之外。
|
|
123
|
+
// dropStatus 让原生 StatusProxy 在允许放置时显示「加号」状态图标(x-dd-drop-ok-add),
|
|
124
|
+
// 作为可见落点反馈;视觉由原生 dd.css 提供,随主题切换,本组件零自定义 CSS。
|
|
125
|
+
// 插入指示符(落点线)复用原生 drop-between.gif,同样零新增 CSS、零硬编码色。
|
|
126
|
+
return (
|
|
127
|
+
<DropTarget
|
|
128
|
+
groups={groups}
|
|
129
|
+
innerRef={ref}
|
|
130
|
+
className={props.className}
|
|
131
|
+
style={{ position: 'relative', ...props.style }}
|
|
132
|
+
overClass={props.overClass}
|
|
133
|
+
validate={props.validate}
|
|
134
|
+
dropStatus="x-dd-drop-ok-add"
|
|
135
|
+
onDragOver={handleOver}
|
|
136
|
+
onDragOut={handleOut}
|
|
137
|
+
onDrop={(data, e) => {
|
|
138
|
+
const idx = ref.current
|
|
139
|
+
? computeInsertIndex(ref.current, e.clientY)
|
|
140
|
+
: props.itemIds?.length ?? 0;
|
|
141
|
+
setMarker(null);
|
|
142
|
+
props.onDropItem?.(data, idx);
|
|
143
|
+
return true;
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{props.children}
|
|
147
|
+
{marker && (
|
|
148
|
+
<div
|
|
149
|
+
className="x-dd-insert-marker"
|
|
150
|
+
style={{
|
|
151
|
+
position: 'absolute',
|
|
152
|
+
top: marker.top,
|
|
153
|
+
left: marker.left,
|
|
154
|
+
width: marker.width,
|
|
155
|
+
height: 16,
|
|
156
|
+
backgroundImage: `url("${resolveThemeImageUrl('tree/drop-between.gif')}")`,
|
|
157
|
+
backgroundRepeat: 'no-repeat',
|
|
158
|
+
backgroundPosition: 'left center',
|
|
159
|
+
pointerEvents: 'none',
|
|
160
|
+
zIndex: 5,
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</DropTarget>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Draggable } from './DragSource';
|
|
3
|
+
import type { DropTargetReg } from './DndContext';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PanelProxy —— 保真复刻 Ext.dd.PanelProxy(面板拖拽代理)。
|
|
7
|
+
*
|
|
8
|
+
* Ext 中 PanelProxy 用于「拖动面板标题栏移动整块面板」:拖动时显示一个面板
|
|
9
|
+
* 形状的代理(ghost),松手后把面板移动到落点(等价 PanelProxy.moveProxy /
|
|
10
|
+
* DD 的 endDrag 重定位),拖到非法区域则 repair 回弹。
|
|
11
|
+
*
|
|
12
|
+
* 本组件复用 Draggable(DragSource)作为引擎,仅额外提供:面板形 ghost 渲染
|
|
13
|
+
* 与「有效放置后按落点重定位面板」的逻辑。面板位置为相对 offsetParent 的绝对
|
|
14
|
+
* 坐标,因此需置于一个 position:relative 的容器内(如 demo 中的工作区)。
|
|
15
|
+
*
|
|
16
|
+
* 说明:底层 DD / DDTarget 的「按 id 查找拖拽数据」能力由 DndManager 统一承担,
|
|
17
|
+
* 等价于 Ext.dd.Registry,故不再单独实现 Registry 组件(内联复用)。
|
|
18
|
+
*/
|
|
19
|
+
export interface PanelProxyProps {
|
|
20
|
+
title: string;
|
|
21
|
+
ddGroup?: string;
|
|
22
|
+
width?: number;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: React.CSSProperties;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
/** 初始位置(相对 offsetParent) */
|
|
27
|
+
initial?: { left: number; top: number };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PanelProxy: React.FC<PanelProxyProps> = (props) => {
|
|
31
|
+
const [pos, setPos] = useState(props.initial ?? { left: 8, top: 8 });
|
|
32
|
+
const width = props.width ?? 170;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Draggable
|
|
36
|
+
ddGroup={props.ddGroup ?? 'panel'}
|
|
37
|
+
getDragData={() => ({ panel: props.title, pos })}
|
|
38
|
+
renderGhost={() => (
|
|
39
|
+
<div className="x-panel" style={{ width, opacity: 0.9 }}>
|
|
40
|
+
<div className="x-panel-header">
|
|
41
|
+
<span className="x-panel-header-text">{props.title}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="x-panel-body">{props.children}</div>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
onValidDrop={(
|
|
47
|
+
target: DropTargetReg | null,
|
|
48
|
+
_data: any,
|
|
49
|
+
_e: PointerEvent,
|
|
50
|
+
ghostXY?: { x: number; y: number },
|
|
51
|
+
) => {
|
|
52
|
+
// 有效放置:把面板移动到「拖动中看到 ghost 所在处」,做到所见即所放。
|
|
53
|
+
// ghostXY 是 StatusProxy 代理层视口左上角(光标 - 抓取偏移),等价 Ext
|
|
54
|
+
// 把 moveProxy 放到 proxy.getXY();用裸光标会让面板偏移「抓取偏移」量、
|
|
55
|
+
// 落点错位(抓取点偏下/偏右)。这里减去工作区左上角得到相对坐标,并夹紧。
|
|
56
|
+
if (target?.el && ghostXY) {
|
|
57
|
+
const r = target.el.getBoundingClientRect();
|
|
58
|
+
const left = Math.min(
|
|
59
|
+
Math.max(0, ghostXY.x - r.left),
|
|
60
|
+
Math.max(0, r.width - width),
|
|
61
|
+
);
|
|
62
|
+
const top = Math.max(0, ghostXY.y - r.top);
|
|
63
|
+
setPos({ left, top });
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
onInvalidDrop={() => {
|
|
67
|
+
/* repair:面板保持原位,StatusProxy 回弹到 repairXY */
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className={'x-panel x-dd-panel' + (props.className ? ' ' + props.className : '')}
|
|
72
|
+
style={{ position: 'absolute', left: pos.left, top: pos.top, width, ...props.style }}
|
|
73
|
+
>
|
|
74
|
+
<div className="x-panel-header x-panel-header-draggable">
|
|
75
|
+
<span className="x-panel-header-text">{props.title}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="x-panel-body">{props.children}</div>
|
|
78
|
+
</div>
|
|
79
|
+
</Draggable>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface PanelResizerProps {
|
|
4
|
+
/** 面板标题(显示在 header)。 */
|
|
5
|
+
title?: string;
|
|
6
|
+
/** 初始高度(px)。 */
|
|
7
|
+
initialHeight?: number;
|
|
8
|
+
/** 最小高度(px)。 */
|
|
9
|
+
minHeight?: number;
|
|
10
|
+
/** 最大高度(px)。 */
|
|
11
|
+
maxHeight?: number;
|
|
12
|
+
/** 面板主体内容。 */
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
/** 附加 className。 */
|
|
15
|
+
className?: string;
|
|
16
|
+
/** 拖拽开始时回调,参数为起始高度。 */
|
|
17
|
+
onResizeStart?: (height: number) => void;
|
|
18
|
+
/** 拖拽过程中回调,参数为当前高度。 */
|
|
19
|
+
onResize?: (height: number) => void;
|
|
20
|
+
/** 拖拽结束时回调,参数为最终高度。 */
|
|
21
|
+
onResizeEnd?: (height: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clamp = (v: number, min: number, max: number) =>
|
|
25
|
+
Math.max(min, Math.min(max, v));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 面板边缘缩放(等价 Ext.ux.PanelResizer)。
|
|
29
|
+
*
|
|
30
|
+
* 在面板底部放置一条 resize 句柄,复用 Ext.dd.DragTracker 的拖拽即改变
|
|
31
|
+
* 高度的语义:newHeight = startHeight + deltaY(句柄在底部、面板顶部锚定,
|
|
32
|
+
* 向下拖 = 变高、向上拖 = 变矮,方向与拖动一致)。支持 minHeight / maxHeight
|
|
33
|
+
* 约束与 onResizeStart / onResize / onResizeEnd 回调。
|
|
34
|
+
*/
|
|
35
|
+
export const PanelResizer: React.FC<PanelResizerProps> = (props) => {
|
|
36
|
+
const {
|
|
37
|
+
title = '面板',
|
|
38
|
+
initialHeight = 160,
|
|
39
|
+
minHeight = 60,
|
|
40
|
+
maxHeight = 320,
|
|
41
|
+
children,
|
|
42
|
+
className,
|
|
43
|
+
onResizeStart,
|
|
44
|
+
onResize,
|
|
45
|
+
onResizeEnd,
|
|
46
|
+
} = props;
|
|
47
|
+
|
|
48
|
+
const [height, setHeight] = useState(initialHeight);
|
|
49
|
+
const [active, setActive] = useState(false);
|
|
50
|
+
const startRef = useRef({ y: 0, h: initialHeight });
|
|
51
|
+
const handleRef = useRef<HTMLDivElement | null>(null);
|
|
52
|
+
|
|
53
|
+
const onPointerDown = useCallback(
|
|
54
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
startRef.current = { y: e.clientY, h: height };
|
|
57
|
+
setActive(true);
|
|
58
|
+
handleRef.current?.setPointerCapture(e.pointerId);
|
|
59
|
+
onResizeStart?.(height);
|
|
60
|
+
},
|
|
61
|
+
[height, onResizeStart],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const onPointerMove = useCallback(
|
|
65
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
66
|
+
if (!active) return;
|
|
67
|
+
const deltaY = e.clientY - startRef.current.y;
|
|
68
|
+
const next = clamp(
|
|
69
|
+
startRef.current.h + deltaY,
|
|
70
|
+
minHeight,
|
|
71
|
+
maxHeight,
|
|
72
|
+
);
|
|
73
|
+
setHeight(next);
|
|
74
|
+
onResize?.(next);
|
|
75
|
+
},
|
|
76
|
+
[active, minHeight, maxHeight, onResize],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const endDrag = useCallback(
|
|
80
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
81
|
+
if (!active) return;
|
|
82
|
+
setActive(false);
|
|
83
|
+
try {
|
|
84
|
+
handleRef.current?.releasePointerCapture(e.pointerId);
|
|
85
|
+
} catch {
|
|
86
|
+
/* 句柄可能已失焦 */
|
|
87
|
+
}
|
|
88
|
+
onResizeEnd?.(height);
|
|
89
|
+
},
|
|
90
|
+
[active, height, onResizeEnd],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const cls = ['x-panel', className].filter(Boolean).join(' ');
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
className={cls}
|
|
98
|
+
style={{
|
|
99
|
+
position: 'relative',
|
|
100
|
+
display: 'flex',
|
|
101
|
+
flexDirection: 'column',
|
|
102
|
+
height,
|
|
103
|
+
boxSizing: 'border-box',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="x-panel-header">
|
|
107
|
+
<span className="x-panel-header-text">{title}</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="x-panel-body" style={{ flex: 1, overflow: 'auto' }}>
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
<div
|
|
113
|
+
className="x-panel-footer"
|
|
114
|
+
style={{
|
|
115
|
+
// 对齐原生 Ext.ux.PanelResizer:原生用在 frame:true 面板上,底边框由 .x-panel-bc 画,
|
|
116
|
+
// 且 framed 面板的 body 无边框(ext-all.css:3797)。我们的简化面板是 non-framed,
|
|
117
|
+
// body 自身已带 1px border-bottom(= 面板底边框,蓝色 #99bbe8),所以 footer 不能再画线,
|
|
118
|
+
// 否则会与 body 底边框叠成「两条线 + 悬浮 icon」。
|
|
119
|
+
// 这里让 footer 脱离文档流、只承载 grip:grip(原生 5px, panel-handle.gif center bottom)
|
|
120
|
+
// 落在面板最底部,其手柄正好压在 body 的那条底边框上 —— 一条线 + 压在在线的手柄。
|
|
121
|
+
// 零新增 CSS、零硬编码色(仅复用原生 panel-handle.gif,由 PanelResizer.css 提供)。
|
|
122
|
+
position: 'absolute',
|
|
123
|
+
left: 0,
|
|
124
|
+
right: 0,
|
|
125
|
+
bottom: 0,
|
|
126
|
+
height: 5,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
ref={handleRef}
|
|
131
|
+
className="x-panel-resize"
|
|
132
|
+
style={{ top: 0 }}
|
|
133
|
+
onPointerDown={onPointerDown}
|
|
134
|
+
onPointerMove={onPointerMove}
|
|
135
|
+
onPointerUp={endDrag}
|
|
136
|
+
onPointerCancel={endDrag}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default PanelResizer;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import React, { Fragment, useRef, useState } from 'react';
|
|
2
|
+
import { DropTarget } from './DropTarget';
|
|
3
|
+
import { Draggable } from './DragSource';
|
|
4
|
+
import { ColumnLayout } from '../ColumnLayout';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Portal / PortalColumn / Portlet —— 保真复刻 Ext.ux.Portal / PortalColumn / Portlet。
|
|
8
|
+
*
|
|
9
|
+
* 等价关系:
|
|
10
|
+
* - Portlet = Ext.ux.Portlet(Ext.Panel + draggable:true → Ext.Panel.DD)→ 本组件的
|
|
11
|
+
* `Draggable`(等价 DragSource)包裹一块 .x-portlet 面板。
|
|
12
|
+
* - Portal = Ext.ux.Portal,内含 `Portal.DropZone`(继承 Ext.dd.DropTarget)。本组件
|
|
13
|
+
* 把「整个门户画布」注册为一个 DropTarget,并在拖拽悬停时:
|
|
14
|
+
* 1) 按指针 X 在 [data-dd-column] 网格中选目标列(等价 DropZone 的 columnX 选择);
|
|
15
|
+
* 2) 按指针 Y 与列内 [data-dd-item] 中点比较算插入位置(等价 getPosition);
|
|
16
|
+
* 3) 在该位置渲染实时插入占位 .x-portal-placeholder(等价 dd.proxy.moveProxy 的
|
|
17
|
+
* 实时占位框);松手时把 Portlet 从源列/位移动到目标列/位。
|
|
18
|
+
* - PortalColumn = Ext.ux.PortalColumn,纯承载容器(自身无 dd 代码)。列布局已抽离为独立
|
|
19
|
+
* ColumnLayout 组件(src/components/ColumnLayout.tsx),精确复刻原生
|
|
20
|
+
* Ext.layout.ColumnLayout 的两遍 pass 算法(固定宽列先扣、columnWidth 直接乘剩余宽、
|
|
21
|
+
* 列 float:left),本组件只复用它,不再内联布局计算、不依赖任何独立 css 文件。
|
|
22
|
+
*
|
|
23
|
+
* 自动滚动(Ext.dd.ScrollManager)在本保真中暂未实现,因演示面板均在视口内即可完成
|
|
24
|
+
* 跨列拖拽;如需长列表可后续补充。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface PortletData {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
body?: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PortalColumnData {
|
|
34
|
+
id: string;
|
|
35
|
+
items: PortletData[];
|
|
36
|
+
/** 列宽占比(等价 Ext.layout.ColumnLayout 的 columnWidth 分数)。不传则等宽(1/n)。 */
|
|
37
|
+
columnWidth?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PortalProps {
|
|
41
|
+
ddGroup?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
style?: React.CSSProperties;
|
|
44
|
+
/** 初始列与 Portlet(仅用于首次初始化,后续状态由组件内部维护) */
|
|
45
|
+
columns: PortalColumnData[];
|
|
46
|
+
/** 放置后回调(等价 DropZone 的 notifyDrop 后的刷新) */
|
|
47
|
+
onChange?: (columns: PortalColumnData[]) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PortletProps {
|
|
51
|
+
data: PortletData;
|
|
52
|
+
groups: string[];
|
|
53
|
+
/** 无效放置(拖到门户外)回调,回弹动画由 StatusProxy 负责 */
|
|
54
|
+
onInvalidDrop?: (data: any, e: PointerEvent) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Portlet: React.FC<PortletProps> = ({
|
|
58
|
+
data,
|
|
59
|
+
groups,
|
|
60
|
+
onInvalidDrop,
|
|
61
|
+
}) => (
|
|
62
|
+
<Draggable
|
|
63
|
+
groups={groups}
|
|
64
|
+
getDragData={() => ({ portletId: data.id })}
|
|
65
|
+
onInvalidDrop={onInvalidDrop}
|
|
66
|
+
>
|
|
67
|
+
{/* 等价原生 Ext.ux.Portlet = Ext.Panel + draggable:外观完全走原生 .x-panel /
|
|
68
|
+
.x-panel-header / .x-panel-body(主题蓝标题由 xtheme 提供),不再自定义卡片样式。
|
|
69
|
+
.x-portlet 仅保留原生 Portal.css 的 margin-bottom:10px(已在 docs/index.html 链接)。 */}
|
|
70
|
+
<div className="x-panel x-portlet" data-dd-item data-id={data.id}>
|
|
71
|
+
<div className="x-panel-header x-panel-header-draggable">
|
|
72
|
+
<span className="x-panel-header-text">{data.title}</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="x-panel-body">{data.body}</div>
|
|
75
|
+
</div>
|
|
76
|
+
</Draggable>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const Placeholder: React.FC<{ title: string }> = ({ title }) => (
|
|
80
|
+
// 等价原生 dd.proxy.moveProxy 的实时占位框;视觉由 ext-theme.css 的 .x-portal-placeholder /
|
|
81
|
+
// .x-portal-placeholder-text 提供(颜色随 xtheme 切换、零硬编码),不再内联硬编码灰阶。
|
|
82
|
+
<div className="x-portal-placeholder" style={{ marginBottom: 10 }}>
|
|
83
|
+
<div className="x-portal-placeholder-text">{title}</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** 按指针 X 在 [data-dd-column] 中选列:横向命中优先,否则取最近列(等价 columnX 网格) */
|
|
88
|
+
function pickColumn(root: HTMLElement, clientX: number): HTMLElement | null {
|
|
89
|
+
const cols = Array.from(
|
|
90
|
+
root.querySelectorAll<HTMLElement>('[data-dd-column]'),
|
|
91
|
+
);
|
|
92
|
+
if (cols.length === 0) return null;
|
|
93
|
+
let best: HTMLElement | null = null;
|
|
94
|
+
let bestDist = Infinity;
|
|
95
|
+
for (const c of cols) {
|
|
96
|
+
const r = c.getBoundingClientRect();
|
|
97
|
+
if (clientX >= r.left && clientX <= r.right) return c;
|
|
98
|
+
const center = r.left + r.width / 2;
|
|
99
|
+
const dist = Math.abs(clientX - center);
|
|
100
|
+
if (dist < bestDist) {
|
|
101
|
+
bestDist = dist;
|
|
102
|
+
best = c;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 按指针 Y 与列内 [data-dd-item] 中点比较,计算插入索引 */
|
|
109
|
+
function computeInsertIndexInColumn(
|
|
110
|
+
colEl: HTMLElement,
|
|
111
|
+
clientY: number,
|
|
112
|
+
): number {
|
|
113
|
+
const items = Array.from(
|
|
114
|
+
colEl.querySelectorAll<HTMLElement>('[data-dd-item]'),
|
|
115
|
+
);
|
|
116
|
+
for (let i = 0; i < items.length; i++) {
|
|
117
|
+
const r = items[i].getBoundingClientRect();
|
|
118
|
+
if (clientY < r.top + r.height / 2) return i;
|
|
119
|
+
}
|
|
120
|
+
return items.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 把 source 列/位移动到 target 列/位(同列前移时修正索引,对齐 Ext.dd.DropZone 行为) */
|
|
124
|
+
function moveItem(
|
|
125
|
+
cols: PortalColumnData[],
|
|
126
|
+
source: { colId: string; index: number },
|
|
127
|
+
target: { colId: string; index: number },
|
|
128
|
+
): PortalColumnData[] {
|
|
129
|
+
const result = cols.map((c) => ({ id: c.id, items: [...c.items] }));
|
|
130
|
+
const srcCol = result.find((c) => c.id === source.colId);
|
|
131
|
+
if (!srcCol) return cols;
|
|
132
|
+
const [moved] = srcCol.items.splice(source.index, 1);
|
|
133
|
+
if (!moved) return cols;
|
|
134
|
+
const tgtCol = result.find((c) => c.id === target.colId) ?? srcCol;
|
|
135
|
+
let tIdx = target.index;
|
|
136
|
+
if (source.colId === target.colId && source.index < target.index) {
|
|
137
|
+
tIdx = target.index - 1;
|
|
138
|
+
}
|
|
139
|
+
tgtCol.items.splice(tIdx, 0, moved);
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface DropPos {
|
|
144
|
+
colId: string;
|
|
145
|
+
index: number;
|
|
146
|
+
title: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const Portal: React.FC<PortalProps> = (props) => {
|
|
150
|
+
const groups = props.ddGroup ? [props.ddGroup] : ['portal'];
|
|
151
|
+
const [columns, setColumns] = useState<PortalColumnData[]>(() =>
|
|
152
|
+
props.columns.map((c) => ({ id: c.id, items: [...c.items] })),
|
|
153
|
+
);
|
|
154
|
+
const [drop, setDrop] = useState<DropPos | null>(null);
|
|
155
|
+
const dropRef = useRef<DropPos | null>(null);
|
|
156
|
+
dropRef.current = drop;
|
|
157
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
158
|
+
const n = columns.length;
|
|
159
|
+
|
|
160
|
+
const findSource = (pid: string): { colId: string; index: number } | null => {
|
|
161
|
+
for (const col of columns) {
|
|
162
|
+
const idx = col.items.findIndex((i) => i.id === pid);
|
|
163
|
+
if (idx >= 0) return { colId: col.id, index: idx };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleOver = (data: any, e: PointerEvent) => {
|
|
169
|
+
const root = rootRef.current;
|
|
170
|
+
if (!root) return;
|
|
171
|
+
const colEl = pickColumn(root, e.clientX);
|
|
172
|
+
if (!colEl) {
|
|
173
|
+
setDrop(null);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const colId = colEl.getAttribute('data-id') || '';
|
|
177
|
+
const index = computeInsertIndexInColumn(colEl, e.clientY);
|
|
178
|
+
const pid = data?.portletId as string | undefined;
|
|
179
|
+
let title = '';
|
|
180
|
+
if (pid) {
|
|
181
|
+
for (const col of columns) {
|
|
182
|
+
const it = col.items.find((i) => i.id === pid);
|
|
183
|
+
if (it) {
|
|
184
|
+
title = it.title;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
setDrop({ colId, index, title });
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleOut = () => setDrop(null);
|
|
193
|
+
|
|
194
|
+
const handleDrop = (data: any, _e: PointerEvent): boolean => {
|
|
195
|
+
const pid = data?.portletId as string | undefined;
|
|
196
|
+
const src = pid ? findSource(pid) : null;
|
|
197
|
+
const d = dropRef.current;
|
|
198
|
+
setDrop(null);
|
|
199
|
+
if (!src || !d) return false;
|
|
200
|
+
const next = moveItem(columns, src, d);
|
|
201
|
+
setColumns(next);
|
|
202
|
+
props.onChange?.(next);
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
className={['x-portal', props.className].filter(Boolean).join(' ')}
|
|
209
|
+
ref={rootRef}
|
|
210
|
+
style={{ padding: 4, ...props.style }}
|
|
211
|
+
>
|
|
212
|
+
<DropTarget
|
|
213
|
+
groups={groups}
|
|
214
|
+
className="x-portal-canvas"
|
|
215
|
+
onDragOver={handleOver}
|
|
216
|
+
onDragOut={handleOut}
|
|
217
|
+
onDrop={handleDrop}
|
|
218
|
+
style={{ position: 'relative', minHeight: 240 }}
|
|
219
|
+
>
|
|
220
|
+
<ColumnLayout
|
|
221
|
+
items={columns.map((col, i) => {
|
|
222
|
+
const active = drop?.colId === col.id;
|
|
223
|
+
return {
|
|
224
|
+
// 等价原生 item.columnWidth(比例,和为 1);未指定则等宽 1/n
|
|
225
|
+
columnWidth: col.columnWidth ?? 1 / n,
|
|
226
|
+
// 列视觉(背景/边框/圆角/内边距/minHeight)由 ext-theme.css 的 .x-portal-column 提供;
|
|
227
|
+
// 拖拽悬停高亮经 .x-portal-column-active 主题类(颜色随 xtheme 切换、零硬编码),
|
|
228
|
+
// 不再内联硬编码灰阶覆盖主题蓝(此前内联色会盖掉主题蓝且失去主题切换能力)。
|
|
229
|
+
cls: active ? 'x-portal-column x-portal-column-active' : 'x-portal-column',
|
|
230
|
+
dataId: col.id,
|
|
231
|
+
// 列间距由原生 margins 提供(第一遍/第二遍都会扣减,确保总宽不溢出)
|
|
232
|
+
margins: i < n - 1 ? '0 14 0 0' : '0',
|
|
233
|
+
node: (
|
|
234
|
+
<>
|
|
235
|
+
{drop && drop.colId === col.id && drop.index === 0 && (
|
|
236
|
+
<Placeholder title={drop.title} />
|
|
237
|
+
)}
|
|
238
|
+
{col.items.map((p, idx) => (
|
|
239
|
+
<Fragment key={p.id}>
|
|
240
|
+
<Portlet data={p} groups={groups} />
|
|
241
|
+
{drop && drop.colId === col.id && drop.index === idx + 1 && (
|
|
242
|
+
<Placeholder title={drop.title} />
|
|
243
|
+
)}
|
|
244
|
+
</Fragment>
|
|
245
|
+
))}
|
|
246
|
+
</>
|
|
247
|
+
),
|
|
248
|
+
};
|
|
249
|
+
})}
|
|
250
|
+
/>
|
|
251
|
+
</DropTarget>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
};
|