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,328 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import ReactDOM from 'react-dom';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DndContext —— 拖放引擎内核 + StatusProxy 渲染层。
|
|
9
|
+
*
|
|
10
|
+
* 保真复刻 Ext.dd 的全局单例 DragDropMgr(DDM)与 Ext.dd.StatusProxy:
|
|
11
|
+
* - DndManager 为模块级单例(等价 Ext.dd.DDM),管理所有已注册的 DropTarget、
|
|
12
|
+
* 当前拖拽状态、pointer 事件派发、相交检测与 enter/over/out/drop 回调。
|
|
13
|
+
* - StatusProxy 为跟随指针的全局拖放代理层(x-dd-drag-proxy),含 drop 状态图标
|
|
14
|
+
* (x-dd-drop-ok / x-dd-drop-nodrop / x-dd-drop-ok-add)与 ghost 内容。
|
|
15
|
+
*
|
|
16
|
+
* 与 Ext 的差异(React 化):
|
|
17
|
+
* - 不逐字移植 YUI 的全局 mousemove/mouseup 监听,改为「拖拽启动时」绑定 window
|
|
18
|
+
* 级 pointer 监听、结束解绑,天然适配 React 组件生命周期。
|
|
19
|
+
* - 相交检测默认 POINT 模式(Ext 默认 mode=0):指针点落在 target 矩形内即 over。
|
|
20
|
+
* - 多个 DndProvider 通过「订阅同一 manager 数据源」渲染,保证全局唯一视觉状态。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type DropStatusClass =
|
|
24
|
+
| 'x-dd-drop-ok'
|
|
25
|
+
| 'x-dd-drop-nodrop'
|
|
26
|
+
| 'x-dd-drop-ok-add';
|
|
27
|
+
|
|
28
|
+
export interface DropTargetReg {
|
|
29
|
+
id: string;
|
|
30
|
+
groups: string[];
|
|
31
|
+
el: HTMLElement;
|
|
32
|
+
overClass?: string;
|
|
33
|
+
/** 返回 true 表示允许放置(drop-ok),false 表示禁止(drop-nodrop) */
|
|
34
|
+
validate?: (data: any, e: PointerEvent) => boolean;
|
|
35
|
+
/** 允许放置时 StatusProxy 显示的状态图标类(等价原生 StatusProxy.setStatus)。
|
|
36
|
+
* 默认 'x-dd-drop-ok'(对勾);放入列表/区域追加可用 'x-dd-drop-ok-add'(加号)。
|
|
37
|
+
* 视觉由原生 ext-all.css / visual/dd.css / 各 theme dd.css 提供,随主题切换。 */
|
|
38
|
+
dropStatus?: DropStatusClass;
|
|
39
|
+
onEnter?: (data: any, e: PointerEvent) => void;
|
|
40
|
+
onOver?: (data: any, e: PointerEvent) => void;
|
|
41
|
+
onOut?: (data: any, e: PointerEvent) => void;
|
|
42
|
+
/** 返回 true 表示有效放置(不触发 repair 回弹) */
|
|
43
|
+
onDrop?: (data: any, e: PointerEvent) => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StartDragOptions {
|
|
47
|
+
data: any;
|
|
48
|
+
groups: string[];
|
|
49
|
+
/** StatusProxy 的 ghost 内容(克隆节点或自定义 React 节点) */
|
|
50
|
+
ghost: React.ReactNode;
|
|
51
|
+
/** 抓取点相对被拖元素左上角的偏移(等价 Ext.dd.DDProxy 的 deltaX/deltaY)。
|
|
52
|
+
* 代理按「指针 - 该偏移」定位,使抓取点始终停在光标下,避免起拖瞬间代理跳变。
|
|
53
|
+
* 默认 0(元素左上角即抓取点)。由 Draggable 在 pointerdown 时按真实抓取位置传入。 */
|
|
54
|
+
offsetX?: number;
|
|
55
|
+
offsetY?: number;
|
|
56
|
+
/** 无效放置时回弹终点(源元素 viewport 位置) */
|
|
57
|
+
repairXY?: { x: number; y: number };
|
|
58
|
+
/** 有效放置回调。第 4 个参数 ghostXY 为当前 StatusProxy 代理层的视口左上角
|
|
59
|
+
* (等价 Ext.dd.DDProxy 的 proxy.getXY():即「光标 - 抓取偏移」后的代理位置),
|
|
60
|
+
* 调用方应用它(而非裸光标 e.clientX/Y)来定位被拖元素,才能做到「所见即所放」、
|
|
61
|
+
* 与拖动中看到的 ghost 完全重合(Ext 原生即把 moveProxy 放到 proxy.getXY())。
|
|
62
|
+
* 注意参数顺序保持兼容:第 2 个始终是拖拽 data,第 3 个始终是事件 e,ghostXY 追加在第 4。 */
|
|
63
|
+
onValidDrop?: (
|
|
64
|
+
target: DropTargetReg | null,
|
|
65
|
+
data: any,
|
|
66
|
+
e: PointerEvent,
|
|
67
|
+
ghostXY?: { x: number; y: number },
|
|
68
|
+
) => void;
|
|
69
|
+
onInvalidDrop?: (e: PointerEvent) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ProxyState {
|
|
73
|
+
visible: boolean;
|
|
74
|
+
status: DropStatusClass;
|
|
75
|
+
x: number;
|
|
76
|
+
y: number;
|
|
77
|
+
ghost: React.ReactNode;
|
|
78
|
+
repairing: boolean;
|
|
79
|
+
/** 当前悬浮(且允许放置)的 DropTarget id,供目标做 over 高亮(响应式) */
|
|
80
|
+
overId: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const INITIAL_PROXY: ProxyState = {
|
|
84
|
+
visible: false,
|
|
85
|
+
status: 'x-dd-drop-nodrop',
|
|
86
|
+
x: 0,
|
|
87
|
+
y: 0,
|
|
88
|
+
ghost: null,
|
|
89
|
+
repairing: false,
|
|
90
|
+
overId: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
class DndManager {
|
|
94
|
+
private drops = new Map<string, DropTargetReg>();
|
|
95
|
+
private drag:
|
|
96
|
+
| { opts: StartDragOptions; overIds: Set<string>; lastOver: DropTargetReg | null }
|
|
97
|
+
| null = null;
|
|
98
|
+
private subscribers = new Set<(s: ProxyState) => void>();
|
|
99
|
+
private proxyState: ProxyState = INITIAL_PROXY;
|
|
100
|
+
private lastX = 0;
|
|
101
|
+
private lastY = 0;
|
|
102
|
+
|
|
103
|
+
subscribe(fn: (s: ProxyState) => void): () => void {
|
|
104
|
+
this.subscribers.add(fn);
|
|
105
|
+
fn(this.proxyState);
|
|
106
|
+
return () => {
|
|
107
|
+
this.subscribers.delete(fn);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getProxyState(): ProxyState {
|
|
112
|
+
return this.proxyState;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private emit() {
|
|
116
|
+
this.subscribers.forEach((fn) => fn(this.proxyState));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private setProxy(s: Partial<ProxyState>) {
|
|
120
|
+
this.proxyState = { ...this.proxyState, ...s };
|
|
121
|
+
this.emit();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
registerDrop(reg: DropTargetReg) {
|
|
125
|
+
this.drops.set(reg.id, reg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
unregisterDrop(id: string) {
|
|
129
|
+
this.drops.delete(id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
isDragging(): boolean {
|
|
133
|
+
return !!this.drag;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
startDrag(opts: StartDragOptions, startEvent: PointerEvent) {
|
|
137
|
+
if (this.drag) return;
|
|
138
|
+
this.drag = { opts, overIds: new Set(), lastOver: null };
|
|
139
|
+
const ox = opts.offsetX ?? 0;
|
|
140
|
+
const oy = opts.offsetY ?? 0;
|
|
141
|
+
// 代理左上角 = 指针 - 抓取偏移,使抓取点保持在光标下(等价 Ext.dd.DDProxy 行为)
|
|
142
|
+
this.lastX = startEvent.clientX - ox;
|
|
143
|
+
this.lastY = startEvent.clientY - oy;
|
|
144
|
+
this.setProxy({
|
|
145
|
+
visible: true,
|
|
146
|
+
status: 'x-dd-drop-nodrop',
|
|
147
|
+
ghost: opts.ghost,
|
|
148
|
+
x: this.lastX,
|
|
149
|
+
y: this.lastY,
|
|
150
|
+
repairing: false,
|
|
151
|
+
overId: null,
|
|
152
|
+
});
|
|
153
|
+
window.addEventListener('pointermove', this.onMove);
|
|
154
|
+
window.addEventListener('pointerup', this.onUp);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private onMove = (e: PointerEvent) => {
|
|
158
|
+
if (!this.drag) return;
|
|
159
|
+
const { opts } = this.drag;
|
|
160
|
+
const ox = opts.offsetX ?? 0;
|
|
161
|
+
const oy = opts.offsetY ?? 0;
|
|
162
|
+
const x = e.clientX - ox;
|
|
163
|
+
const y = e.clientY - oy;
|
|
164
|
+
this.lastX = x;
|
|
165
|
+
this.lastY = y;
|
|
166
|
+
this.setProxy({ x, y });
|
|
167
|
+
|
|
168
|
+
const groups = opts.groups;
|
|
169
|
+
const pt = { x: e.clientX, y: e.clientY };
|
|
170
|
+
const newOvers: DropTargetReg[] = [];
|
|
171
|
+
this.drops.forEach((d) => {
|
|
172
|
+
if (d.groups.length === 0 || !d.groups.some((g) => groups.includes(g))) return;
|
|
173
|
+
const r = d.el.getBoundingClientRect();
|
|
174
|
+
const over =
|
|
175
|
+
pt.x >= r.left && pt.x <= r.right && pt.y >= r.top && pt.y <= r.bottom;
|
|
176
|
+
if (over) newOvers.push(d);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 离开(out)检测:上一帧 over 但本帧不在
|
|
180
|
+
this.drag.overIds.forEach((id) => {
|
|
181
|
+
if (!newOvers.some((d) => d.id === id)) {
|
|
182
|
+
const d = this.drops.get(id);
|
|
183
|
+
if (d) d.onOut?.(opts.data, e);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 进入(enter)/ 悬浮(over)+ 状态图标
|
|
188
|
+
let allowedStatus: DropStatusClass | null = null;
|
|
189
|
+
let allowedId: string | null = null;
|
|
190
|
+
newOvers.forEach((d) => {
|
|
191
|
+
const isEnter = !this.drag!.overIds.has(d.id);
|
|
192
|
+
const allowed = d.validate ? d.validate(opts.data, e) : true;
|
|
193
|
+
if (isEnter) d.onEnter?.(opts.data, e);
|
|
194
|
+
else d.onOver?.(opts.data, e);
|
|
195
|
+
// 记录首个允许放置的目标:其 dropStatus 决定 StatusProxy 图标(默认对勾 ok)
|
|
196
|
+
if (allowed && allowedStatus === null) {
|
|
197
|
+
allowedStatus = d.dropStatus ?? 'x-dd-drop-ok';
|
|
198
|
+
allowedId = d.id;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this.drag.overIds = new Set(newOvers.map((d) => d.id));
|
|
203
|
+
this.drag.lastOver = newOvers.length ? newOvers[newOvers.length - 1] : null;
|
|
204
|
+
|
|
205
|
+
// 状态图标:有目标且至少允许一个 → 首个允许目标的 dropStatus(默认 ok);
|
|
206
|
+
// 有目标但全部禁止 → nodrop;无目标 → nodrop。
|
|
207
|
+
const status: DropStatusClass = newOvers.length
|
|
208
|
+
? allowedStatus ?? 'x-dd-drop-nodrop'
|
|
209
|
+
: 'x-dd-drop-nodrop';
|
|
210
|
+
// 当前允许放置的目标(供响应式 over 高亮);取首个允许
|
|
211
|
+
const overId = newOvers.length ? allowedId : null;
|
|
212
|
+
this.setProxy({ status, overId });
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
private onUp = (e: PointerEvent) => {
|
|
216
|
+
if (!this.drag) return;
|
|
217
|
+
const { opts } = this.drag;
|
|
218
|
+
window.removeEventListener('pointermove', this.onMove);
|
|
219
|
+
window.removeEventListener('pointerup', this.onUp);
|
|
220
|
+
|
|
221
|
+
const target = this.drag.lastOver;
|
|
222
|
+
let valid = false;
|
|
223
|
+
if (target) {
|
|
224
|
+
const allowed = target.validate ? target.validate(opts.data, e) : true;
|
|
225
|
+
if (allowed) {
|
|
226
|
+
valid = target.onDrop ? target.onDrop(opts.data, e) : true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (valid) {
|
|
231
|
+
// 代理层(x-dd-drag-proxy)的视口左上角 = 光标 - 抓取偏移(lastX/lastY),
|
|
232
|
+
// 等价 Ext.dd.DDProxy.getXY()。把它传给 onValidDrop,调用方据此定位被拖元素,
|
|
233
|
+
// 使落点「所见即所放」(与拖动中看到的 ghost 对齐),避免裸光标 e.clientX/Y
|
|
234
|
+
// 把面板偏移「抓取偏移」量、落点错位。
|
|
235
|
+
opts.onValidDrop?.(target, opts.data, e, { x: this.lastX, y: this.lastY });
|
|
236
|
+
this.setProxy({ visible: false, ghost: null, repairing: false, overId: null });
|
|
237
|
+
} else {
|
|
238
|
+
opts.onInvalidDrop?.(e);
|
|
239
|
+
const xy = opts.repairXY ?? { x: this.lastX, y: this.lastY };
|
|
240
|
+
this.setProxy({ repairing: true, x: xy.x, y: xy.y, overId: null });
|
|
241
|
+
window.setTimeout(() => {
|
|
242
|
+
this.setProxy({ visible: false, ghost: null, repairing: false, overId: null });
|
|
243
|
+
}, 500);
|
|
244
|
+
}
|
|
245
|
+
this.drag = null;
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const dndManager = new DndManager();
|
|
250
|
+
|
|
251
|
+
/* ----------------------------- DndProvider ----------------------------- */
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* DndProvider —— 保真复刻 Ext 中「一个拖放上下文」的语义容器。
|
|
255
|
+
*
|
|
256
|
+
* 在 React 化架构中,Draggable / DropTarget 通过模块级单例 `dndManager` 通信,
|
|
257
|
+
* 不依赖 React context,因此 DndProvider 仅作语义包裹(保留与 Ext 一致的写法:
|
|
258
|
+
* 把一组拖放源/目标放进同一个 DndProvider)。全局唯一的 StatusProxy 由
|
|
259
|
+
* `DndStatusProxy` 在应用根挂载一次,避免每个 DndProvider 都渲染一个代理。
|
|
260
|
+
*/
|
|
261
|
+
export interface DndProviderProps {
|
|
262
|
+
children?: React.ReactNode;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const DndProvider: React.FC<DndProviderProps> = ({ children }) => {
|
|
266
|
+
return <>{children}</>;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* useDndState —— 订阅 DndManager 的 proxyState(含拖放代理视觉状态与当前
|
|
271
|
+
* over 的 DropTarget id)。用于在 React 渲染周期内响应式反映拖放状态,
|
|
272
|
+
* 替代 Ext 中命令式操作 DOM class 的做法。
|
|
273
|
+
*/
|
|
274
|
+
export function useDndState(): ProxyState {
|
|
275
|
+
const [state, setState] = useState<ProxyState>(dndManager.getProxyState());
|
|
276
|
+
useEffect(() => dndManager.subscribe(setState), []);
|
|
277
|
+
return state;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* DndStatusProxy —— 全局唯一的 StatusProxy 渲染层。
|
|
282
|
+
*
|
|
283
|
+
* 等价于 Ext 在 document 上唯一存在的拖放代理节点。应且仅应在应用根挂载一次
|
|
284
|
+
* (见 docs/src/App.tsx)。订阅 dndManager 的 proxyState,把跟随指针的代理
|
|
285
|
+
* 通过 createPortal 渲染到 document.body。
|
|
286
|
+
*/
|
|
287
|
+
export const DndStatusProxy: React.FC = () => {
|
|
288
|
+
const proxy = useDndState();
|
|
289
|
+
return <StatusProxyLayer state={proxy} />;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function StatusProxyLayer({ state }: { state: ProxyState }) {
|
|
293
|
+
if (!state.visible || typeof document === 'undefined') return null;
|
|
294
|
+
return ReactDOM.createPortal(
|
|
295
|
+
<div
|
|
296
|
+
className={
|
|
297
|
+
'x-dd-drag-proxy ' +
|
|
298
|
+
state.status +
|
|
299
|
+
(state.repairing ? ' x-dd-drag-repair' : '')
|
|
300
|
+
}
|
|
301
|
+
style={{
|
|
302
|
+
// 等价原生 Ext.dd.StatusProxy:代理层全程跟随「视口坐标」的指针。
|
|
303
|
+
// 这里用 position:fixed(相对视口),与 DragSource 计算 grabOffset / repairXY /
|
|
304
|
+
// onDrop 时统一使用的 clientX/clientY(视口坐标)一致,避免原生 absolute 把 left/top
|
|
305
|
+
// 当页面坐标解释——一旦页面或内部滚动容器滚动,代理就会相对光标偏移(抓取点漂移)。
|
|
306
|
+
position: 'fixed',
|
|
307
|
+
left: state.x + 'px',
|
|
308
|
+
top: state.y + 'px',
|
|
309
|
+
zIndex: 15000,
|
|
310
|
+
visibility: 'visible',
|
|
311
|
+
pointerEvents: 'none',
|
|
312
|
+
// 等价原生 .x-dd-drag-proxy 的 user-select:none(原生 x-unselectable 的
|
|
313
|
+
// -webkit-user-select:ignore 在 Chrome 失效,故显式声明;内联以删除独立 css 文件)。
|
|
314
|
+
userSelect: 'none',
|
|
315
|
+
WebkitUserSelect: 'none',
|
|
316
|
+
// 回弹动画:等价 Ext StatusProxy.repair 的 .5s 位移(原生用动画框架,这里以
|
|
317
|
+
// 组件内联 transition 复刻,避免往主题表写规则)。图标外观仍由原生 ext-all.css 提供。
|
|
318
|
+
transition: state.repairing
|
|
319
|
+
? 'left .5s ease-out, top .5s ease-out'
|
|
320
|
+
: undefined,
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
<div className="x-dd-drop-icon" />
|
|
324
|
+
<div className="x-dd-drag-ghost">{state.ghost}</div>
|
|
325
|
+
</div>,
|
|
326
|
+
document.body,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { dndManager, type DropTargetReg } from './DndContext';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Draggable —— 保真复刻 Ext.dd.DragSource(含 DDProxy 行为)。
|
|
6
|
+
*
|
|
7
|
+
* 交互契约(对齐 Ext):
|
|
8
|
+
* - onPointerDown 记录起点 + 绑定 window 级 pointermove/up,但**不立即起拖**。
|
|
9
|
+
* - 只有当指针移动超过 clickPixelThresh(默认 5px,等价 Ext.dd.DragSource.dragThreshold)
|
|
10
|
+
* 才真正起拖;纯点击(按下后未移动即抬起)不进入拖拽态,按钮的 click 正常触发。
|
|
11
|
+
* - 起拖后由 DndManager 驱动 StatusProxy 跟随指针(默认偏移 0,图标贴住光标)。
|
|
12
|
+
* - 放置成功时调用 onValidDrop(target, data, e);无效时调用 onInvalidDrop(data, e),
|
|
13
|
+
* 并由 StatusProxy 执行 repair 回弹动画(回弹到源元素位置 repairXY)。
|
|
14
|
+
*
|
|
15
|
+
* 底层 DD / DDProxy / DDTarget 作为引擎被 DndManager 复用,无需单独成组件。
|
|
16
|
+
*/
|
|
17
|
+
/** 等价 Ext.dd.DragSource.clickPixelThresh / dragThreshold,默认 5px */
|
|
18
|
+
const DRAG_THRESHOLD = 5;
|
|
19
|
+
|
|
20
|
+
export interface DraggableProps {
|
|
21
|
+
/** 拖放组(等价 Ext ddGroup / group),与 DropTarget 同组才交互,默认 ['default'] */
|
|
22
|
+
ddGroup?: string;
|
|
23
|
+
groups?: string[];
|
|
24
|
+
/** 取拖拽数据(等价 getDragData) */
|
|
25
|
+
getDragData?: (e: React.PointerEvent) => any;
|
|
26
|
+
/** 起拖前钩子(等价 onBeforeDrag),返回 false 取消拖拽 */
|
|
27
|
+
onBeforeDrag?: (data: any, e: React.PointerEvent) => boolean;
|
|
28
|
+
/** 自定义 StatusProxy 的 ghost 内容(等价 onInitDrag 克隆节点) */
|
|
29
|
+
renderGhost?: (data: any) => React.ReactNode;
|
|
30
|
+
/** 有效放置回调(等价 onValidDrop 后的 afterValidDrop)。第 4 个参数 ghostXY 为
|
|
31
|
+
* StatusProxy 代理层视口左上角(等价 Ext proxy.getXY()),用于「所见即所放」精确定位。 */
|
|
32
|
+
onValidDrop?: (
|
|
33
|
+
target: DropTargetReg | null,
|
|
34
|
+
data: any,
|
|
35
|
+
e: PointerEvent,
|
|
36
|
+
ghostXY?: { x: number; y: number },
|
|
37
|
+
) => void;
|
|
38
|
+
/** 无效放置回调(等价 onInvalidDrop) */
|
|
39
|
+
onInvalidDrop?: (data: any, e: PointerEvent) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
style?: React.CSSProperties;
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Draggable: React.FC<DraggableProps> = (props) => {
|
|
46
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
47
|
+
// 待定拖拽上下文:pointerdown 后、超过阈值前暂存于此
|
|
48
|
+
const pending = useRef<{
|
|
49
|
+
startX: number;
|
|
50
|
+
startY: number;
|
|
51
|
+
data: any;
|
|
52
|
+
groups: string[];
|
|
53
|
+
ghost: React.ReactNode;
|
|
54
|
+
repairXY?: { x: number; y: number };
|
|
55
|
+
grabOffset?: { x: number; y: number };
|
|
56
|
+
onValidDrop?: (
|
|
57
|
+
t: DropTargetReg | null,
|
|
58
|
+
data: any,
|
|
59
|
+
e: PointerEvent,
|
|
60
|
+
ghostXY?: { x: number; y: number },
|
|
61
|
+
) => void;
|
|
62
|
+
onInvalidDrop?: (data: any, e: PointerEvent) => void;
|
|
63
|
+
pointerId: number;
|
|
64
|
+
} | null>(null);
|
|
65
|
+
const moveRef = useRef<((e: PointerEvent) => void) | null>(null);
|
|
66
|
+
const upRef = useRef<((e: PointerEvent) => void) | null>(null);
|
|
67
|
+
|
|
68
|
+
const clearPending = () => {
|
|
69
|
+
if (moveRef.current) window.removeEventListener('pointermove', moveRef.current);
|
|
70
|
+
if (upRef.current) window.removeEventListener('pointerup', upRef.current);
|
|
71
|
+
window.removeEventListener('pointercancel', upRef.current!);
|
|
72
|
+
pending.current = null;
|
|
73
|
+
moveRef.current = null;
|
|
74
|
+
upRef.current = null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
78
|
+
if (e.button !== 0) return;
|
|
79
|
+
const data = props.getDragData ? props.getDragData(e) : {};
|
|
80
|
+
if (props.onBeforeDrag && props.onBeforeDrag(data, e) === false) return;
|
|
81
|
+
|
|
82
|
+
const groups =
|
|
83
|
+
props.groups ?? (props.ddGroup ? [props.ddGroup] : ['default']);
|
|
84
|
+
// 默认忠实克隆被拖元素(等价 Ext.dd.StatusProxy 克隆 dragEl):代理尺寸与抓取元素 1:1,
|
|
85
|
+
// 抓取偏移(grabOffset)精确映射,起拖/拖拽中不跳变。仅当显式传 renderGhost 时才用自定义。
|
|
86
|
+
const ghost: React.ReactNode = props.renderGhost
|
|
87
|
+
? props.renderGhost(data)
|
|
88
|
+
: ref.current
|
|
89
|
+
? // 忠实克隆被拖元素(等价 Ext.dd.StatusProxy 克隆 dragEl):原生 Ext 克隆时
|
|
90
|
+
// 会保留源元素宽度(proxy.dom.width = el.dom.width),否则克隆体进入
|
|
91
|
+
// StatusProxy 的「宽度收缩」容器后会塌缩成内容宽(门户跨列拖拽的 Portlet
|
|
92
|
+
// 克隆体比原面板窄、布局变乱即此因)。这里捕获包裹 div 的渲染宽度(=列宽)
|
|
93
|
+
// 并显式设到克隆外层,使 ghost 与原面板 1:1,起拖/拖拽中不跳变、不变形。
|
|
94
|
+
(() => {
|
|
95
|
+
const w = ref.current ? ref.current.offsetWidth : 0;
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
style={w ? { width: w } : undefined}
|
|
99
|
+
dangerouslySetInnerHTML={{ __html: ref.current!.outerHTML }}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
})()
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
let repairXY: { x: number; y: number } | undefined;
|
|
106
|
+
let grabOffset: { x: number; y: number } | undefined;
|
|
107
|
+
if (ref.current) {
|
|
108
|
+
// 取「真实被拖元素」的盒子作抓取偏移基准。默认用包裹 div 自身;但当包裹 div 因内部
|
|
109
|
+
// 子节点 position:absolute 而高度塌缩为 0 时(如 PanelProxy:被拖面板是绝对定位),
|
|
110
|
+
// 改用首个有尺寸的子孙元素(即真实面板)的盒子,否则 grabOffset 会带上多余的绝对定位
|
|
111
|
+
// 偏移(pos.top 等),导致拖拽时光标相对 ghost 整体下移、像「跑到了面板下边框」。
|
|
112
|
+
// 等价原生 Ext.dd.DD:delta 由 getEl()(真实被拖元素)坐标算出,而非外层包裹。
|
|
113
|
+
let box: HTMLElement = ref.current;
|
|
114
|
+
const selfRect = ref.current.getBoundingClientRect();
|
|
115
|
+
if ((selfRect.height < 1 || selfRect.width < 1) && ref.current.firstElementChild) {
|
|
116
|
+
box = ref.current.firstElementChild as HTMLElement;
|
|
117
|
+
}
|
|
118
|
+
const r = box.getBoundingClientRect();
|
|
119
|
+
repairXY = { x: r.left, y: r.top };
|
|
120
|
+
// 抓取点相对被拖元素左上角的偏移(等价 Ext.dd.DDProxy 的 deltaX/deltaY),
|
|
121
|
+
// 传入后代理按「指针 - 该偏移」定位,抓取点始终停在光标下,起拖不跳变。
|
|
122
|
+
grabOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pending.current = {
|
|
126
|
+
startX: e.clientX,
|
|
127
|
+
startY: e.clientY,
|
|
128
|
+
data,
|
|
129
|
+
groups,
|
|
130
|
+
ghost,
|
|
131
|
+
repairXY,
|
|
132
|
+
grabOffset,
|
|
133
|
+
onValidDrop: props.onValidDrop,
|
|
134
|
+
onInvalidDrop: props.onInvalidDrop,
|
|
135
|
+
pointerId: e.pointerId,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
moveRef.current = (ev: PointerEvent) => {
|
|
139
|
+
const p = pending.current;
|
|
140
|
+
if (!p || ev.pointerId !== p.pointerId) return;
|
|
141
|
+
const dx = ev.clientX - p.startX;
|
|
142
|
+
const dy = ev.clientY - p.startY;
|
|
143
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
144
|
+
|
|
145
|
+
// 超过阈值 → 真正起拖;抑制随后冒出的 click,避免误触被包裹的按钮
|
|
146
|
+
pending.current = null;
|
|
147
|
+
window.removeEventListener('pointermove', moveRef.current!);
|
|
148
|
+
window.removeEventListener('pointerup', upRef.current!);
|
|
149
|
+
window.removeEventListener('pointercancel', upRef.current!);
|
|
150
|
+
moveRef.current = null;
|
|
151
|
+
upRef.current = null;
|
|
152
|
+
const stopClick = (ce: MouseEvent) => {
|
|
153
|
+
ce.stopPropagation();
|
|
154
|
+
ce.preventDefault();
|
|
155
|
+
ce.stopImmediatePropagation();
|
|
156
|
+
window.removeEventListener('click', stopClick, true);
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener('click', stopClick, true);
|
|
159
|
+
|
|
160
|
+
dndManager.startDrag(
|
|
161
|
+
{
|
|
162
|
+
data: p.data,
|
|
163
|
+
groups: p.groups,
|
|
164
|
+
ghost: p.ghost,
|
|
165
|
+
repairXY: p.repairXY,
|
|
166
|
+
offsetX: p.grabOffset?.x ?? 0,
|
|
167
|
+
offsetY: p.grabOffset?.y ?? 0,
|
|
168
|
+
onValidDrop: (t, data, ev2, ghostXY) =>
|
|
169
|
+
p.onValidDrop?.(t, data, ev2, ghostXY),
|
|
170
|
+
onInvalidDrop: (ev2) => p.onInvalidDrop?.(p.data, ev2),
|
|
171
|
+
},
|
|
172
|
+
ev,
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
upRef.current = (ev: PointerEvent) => {
|
|
177
|
+
const p = pending.current;
|
|
178
|
+
if (p && ev.pointerId === p.pointerId) {
|
|
179
|
+
// 未超过阈值即抬起 → 纯点击,清理待定,让 click 正常触发
|
|
180
|
+
clearPending();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
window.addEventListener('pointermove', moveRef.current);
|
|
185
|
+
window.addEventListener('pointerup', upRef.current);
|
|
186
|
+
window.addEventListener('pointercancel', upRef.current);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
ref={ref}
|
|
192
|
+
className={props.className}
|
|
193
|
+
// 等价原生 Ext.dd 给拖拽元素加 x-unselectable:拖拽中不框选文本。
|
|
194
|
+
// user-select 为继承属性,故作用在 Draggable 包装层即可透传到 .x-combo-list-item
|
|
195
|
+
// 等子项;内联声明以避免引入独立 css(与 DndContext 中 StatusProxy 的 userSelect 保持一致)。
|
|
196
|
+
style={{ userSelect: 'none', ...props.style }}
|
|
197
|
+
onPointerDown={onPointerDown}
|
|
198
|
+
>
|
|
199
|
+
{props.children}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Draggable } from './DragSource';
|
|
3
|
+
import type { DropTargetReg } from './DndContext';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DragZone —— 保真复刻 Ext.dd.DragZone(含 DDProxy 行为)。
|
|
7
|
+
*
|
|
8
|
+
* DragZone 是一个「拖拽区」容器:其下的子项(item)均可作为拖拽源,等价于
|
|
9
|
+
* Ext 示例中把容器包成 DragZone 后,内部节点自动可拖。每个 item 渲染为
|
|
10
|
+
* Draggable(等价 DragSource),并带 `data-dd-item` / `data-id` 标记,供
|
|
11
|
+
* DropZone 计算插入位置。
|
|
12
|
+
*
|
|
13
|
+
* 与 DropTarget/DropZone 配合即可实现「列表重排」「跨列表移动」等经典拖放。
|
|
14
|
+
*/
|
|
15
|
+
export interface DragZoneItem {
|
|
16
|
+
id: string;
|
|
17
|
+
content: React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DragZoneProps {
|
|
21
|
+
ddGroup?: string;
|
|
22
|
+
groups?: string[];
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: React.CSSProperties;
|
|
25
|
+
/** 可拖拽项(顺序即视觉顺序) */
|
|
26
|
+
items: DragZoneItem[];
|
|
27
|
+
/** 取拖拽数据(等价 DragZone.getDragData),默认返回 { item } */
|
|
28
|
+
getDragData?: (item: DragZoneItem) => any;
|
|
29
|
+
/** 自定义 StatusProxy 的 ghost 内容(等价 onInitDrag 克隆节点) */
|
|
30
|
+
renderGhost?: (item: DragZoneItem) => React.ReactNode;
|
|
31
|
+
/** 有效放置回调(等价 DragSource.onValidDrop)。第 4 个参数 ghostXY 为
|
|
32
|
+
* StatusProxy 代理层视口左上角(等价 Ext proxy.getXY()),用于精确落点定位。 */
|
|
33
|
+
onValidDrop?: (
|
|
34
|
+
target: DropTargetReg | null,
|
|
35
|
+
data: any,
|
|
36
|
+
e: PointerEvent,
|
|
37
|
+
ghostXY?: { x: number; y: number },
|
|
38
|
+
) => void;
|
|
39
|
+
/** 单个 item 的 className(等价 Ext 节点 class,如 x-tree-node) */
|
|
40
|
+
itemClassName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DragZone: React.FC<DragZoneProps> = (props) => {
|
|
44
|
+
const groups = props.groups ?? (props.ddGroup ? [props.ddGroup] : ['default']);
|
|
45
|
+
return (
|
|
46
|
+
<div className={props.className} style={props.style}>
|
|
47
|
+
{props.items.map((it) => (
|
|
48
|
+
<Draggable
|
|
49
|
+
key={it.id}
|
|
50
|
+
groups={groups}
|
|
51
|
+
getDragData={() =>
|
|
52
|
+
props.getDragData ? props.getDragData(it) : { item: it }
|
|
53
|
+
}
|
|
54
|
+
renderGhost={() =>
|
|
55
|
+
props.renderGhost ? props.renderGhost(it) : it.content
|
|
56
|
+
}
|
|
57
|
+
onValidDrop={props.onValidDrop}
|
|
58
|
+
>
|
|
59
|
+
<div className={props.itemClassName} data-dd-item data-id={it.id}>
|
|
60
|
+
{it.content}
|
|
61
|
+
</div>
|
|
62
|
+
</Draggable>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from 'react';
|
|
2
|
+
import { dndManager, DropStatusClass, DropTargetReg, useDndState } from './DndContext';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DropTarget —— 保真复刻 Ext.dd.DropTarget(含 DDTarget 行为)。
|
|
6
|
+
*
|
|
7
|
+
* 交互契约(对齐 Ext):
|
|
8
|
+
* - 注册到 DndManager(等价 DDTarget 的 isTarget:true + isNotifyTarget:true)。
|
|
9
|
+
* - 拖拽源悬浮时:onDragEnter 触发(等价 notifyEnter,加 overClass)、onDragOver(等价 notifyOver)。
|
|
10
|
+
* - 拖拽源离开:onDragOut(等价 notifyOut,移除 overClass)。
|
|
11
|
+
* - 松手放置:onDrop(等价 notifyDrop,返回 true 表示有效放置,否则触发源 repair)。
|
|
12
|
+
* - validate 返回 false 时 StatusProxy 显示 drop-nodrop(红叉)且不会调用 onDrop。
|
|
13
|
+
*
|
|
14
|
+
* overClass 默认等价 Ext.dd.DropTarget 的 overClass 配置(如 'x-dd-drop-over')。
|
|
15
|
+
*/
|
|
16
|
+
export interface DropTargetProps {
|
|
17
|
+
ddGroup?: string;
|
|
18
|
+
groups?: string[];
|
|
19
|
+
/** 悬浮时加到 el 上的 CSS 类(等价 DropTarget.overClass) */
|
|
20
|
+
overClass?: string;
|
|
21
|
+
/** 是否允许放置(等价 notifyOver 返回 dropAllowed/dropNotAllowed 的判断) */
|
|
22
|
+
validate?: (data: any, e: PointerEvent) => boolean;
|
|
23
|
+
/** 允许放置时 StatusProxy 显示的状态图标类(默认 'x-dd-drop-ok',落入列表/区域追加用 'x-dd-drop-ok-add') */
|
|
24
|
+
dropStatus?: DropStatusClass;
|
|
25
|
+
onDragEnter?: (data: any, e: PointerEvent) => void;
|
|
26
|
+
onDragOver?: (data: any, e: PointerEvent) => void;
|
|
27
|
+
onDragOut?: (data: any, e: PointerEvent) => void;
|
|
28
|
+
onDrop?: (data: any, e: PointerEvent) => boolean;
|
|
29
|
+
className?: string;
|
|
30
|
+
style?: React.CSSProperties;
|
|
31
|
+
children?: React.ReactNode;
|
|
32
|
+
id?: string;
|
|
33
|
+
/**
|
|
34
|
+
* 转发容器 DOM 节点。
|
|
35
|
+
* 供 DropZone 这类需要「以自身节点作为放置区域 + 同时在其内渲染指示线」的组件复用同一节点,
|
|
36
|
+
* 避免再套一层无类名的内层 div(那会使容器的 padding 区域落在放置区之外,
|
|
37
|
+
* 与原生 Ext.dd.DropZone「整个 zone 元素都是放置目标」的语义不符)。
|
|
38
|
+
*/
|
|
39
|
+
innerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DropTarget: React.FC<DropTargetProps> = (props) => {
|
|
43
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
44
|
+
const id = props.id ?? useId();
|
|
45
|
+
const propsRef = useRef(props);
|
|
46
|
+
propsRef.current = props;
|
|
47
|
+
const dnd = useDndState();
|
|
48
|
+
const over = props.overClass ? dnd.overId === id : false;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const reg: DropTargetReg = {
|
|
52
|
+
id,
|
|
53
|
+
groups:
|
|
54
|
+
propsRef.current.groups ??
|
|
55
|
+
(propsRef.current.ddGroup ? [propsRef.current.ddGroup] : ['default']),
|
|
56
|
+
el: ref.current!,
|
|
57
|
+
overClass: propsRef.current.overClass,
|
|
58
|
+
validate: (data, e) => propsRef.current.validate?.(data, e) ?? true,
|
|
59
|
+
dropStatus: propsRef.current.dropStatus,
|
|
60
|
+
onEnter: (data, e) => propsRef.current.onDragEnter?.(data, e),
|
|
61
|
+
onOver: (data, e) => propsRef.current.onDragOver?.(data, e),
|
|
62
|
+
onOut: (data, e) => propsRef.current.onDragOut?.(data, e),
|
|
63
|
+
onDrop: (data, e) => propsRef.current.onDrop?.(data, e) ?? true,
|
|
64
|
+
};
|
|
65
|
+
dndManager.registerDrop(reg);
|
|
66
|
+
return () => dndManager.unregisterDrop(id);
|
|
67
|
+
}, [id]);
|
|
68
|
+
|
|
69
|
+
const cls = [props.className, over ? props.overClass : null]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(' ');
|
|
72
|
+
|
|
73
|
+
// over 高亮:仅按交互态给 el 注入 overClass(默认 'x-dd-drop-over',与原生 DropTarget.overClass
|
|
74
|
+
// 对齐)。原生 ext-all.css / structure/dd.css / theme-*/dd.css 均未给该类配视觉规则,故此处亦不
|
|
75
|
+
// 新增 CSS、不内联颜色——是否渲染由原生 dd.css 或应用决定,本组件只负责加类名(兼容原生 API)。
|
|
76
|
+
const setRef = (node: HTMLDivElement | null) => {
|
|
77
|
+
ref.current = node;
|
|
78
|
+
if (props.innerRef) props.innerRef.current = node;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div ref={setRef} className={cls} style={props.style}>
|
|
83
|
+
{props.children}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|