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,403 @@
|
|
|
1
|
+
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
import { BorderLayout } from './BorderLayout';
|
|
3
|
+
import { Toolbar } from './Toolbar';
|
|
4
|
+
import { TreePanel, type TreeNodeData, type TreePanelHandle } from './Tree';
|
|
5
|
+
|
|
6
|
+
export interface OrganizerImage {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
/** 缩略图底色(无真实图片时用色块近似,等价 ext thumb 背景 #dddddd) */
|
|
10
|
+
color?: string;
|
|
11
|
+
/** 缩略图地址(可选;提供则渲染 <img>,否则用内联 SVG 自包含缩略图等价原始 img) */
|
|
12
|
+
url?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OrganizerProps {
|
|
16
|
+
/** 初始相册(树节点),默认空数组(隐藏根、顶层即相册) */
|
|
17
|
+
albums?: TreeNodeData[];
|
|
18
|
+
/** 初始图片(右栏网格),默认空数组 */
|
|
19
|
+
images?: OrganizerImage[];
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
/** 新建相册按钮文案,默认 'New Album' */
|
|
23
|
+
newAlbumText?: string;
|
|
24
|
+
/** 新建相册默认名前缀,默认 'Album '(配合自增序号) */
|
|
25
|
+
albumNamePrefix?: string;
|
|
26
|
+
/** 相册树变化回调(增/删/改名/重排后,等价 Tree 的 change 事件) */
|
|
27
|
+
onAlbumsChange?: (albums: TreeNodeData[]) => void;
|
|
28
|
+
/** 图片被拖入相册时回调(单张),批量拖拽时为每张各回调一次 */
|
|
29
|
+
onImageDropped?: (image: OrganizerImage, albumId: string) => void;
|
|
30
|
+
/** 图片数据变化回调(增/删后) */
|
|
31
|
+
onImagesChange?: (images: OrganizerImage[]) => void;
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OrganizerHandle {
|
|
37
|
+
/** 新增相册(自动命名并进入内联改名态),返回新节点 id */
|
|
38
|
+
addAlbum: (text?: string) => string;
|
|
39
|
+
/** 新增一张图片到网格 */
|
|
40
|
+
addImage: (image: OrganizerImage) => void;
|
|
41
|
+
/** 读取当前相册树(实时快照) */
|
|
42
|
+
getAlbums: () => TreeNodeData[];
|
|
43
|
+
/** 读取当前图片列表 */
|
|
44
|
+
getImages: () => OrganizerImage[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ORG_IMAGE_MIME = 'application/x-organizer-image';
|
|
48
|
+
const ORG_IMAGE_LIST_MIME = 'application/x-organizer-image-list';
|
|
49
|
+
|
|
50
|
+
/** 自包含 SVG 缩略图 data-uri(等价原始 DataView 的 <img> 资源) */
|
|
51
|
+
const makeThumb = (color: string, label: string): string => {
|
|
52
|
+
const svg =
|
|
53
|
+
`<svg xmlns='http://www.w3.org/2000/svg' width='80' height='60'>` +
|
|
54
|
+
`<rect width='80' height='60' fill='${color}'/>` +
|
|
55
|
+
`<text x='40' y='35' font-size='11' fill='#333' text-anchor='middle' ` +
|
|
56
|
+
`font-family='sans-serif'>${label.replace(/[<&]/g, '')}</text></svg>`;
|
|
57
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const thumbUrl = (img: OrganizerImage): string =>
|
|
61
|
+
img.url ?? makeThumb(img.color ?? '#dddddd', img.name);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Organizer —— 保真复刻 examples/organizer/organizer.js(图片组织管理器)。
|
|
65
|
+
*
|
|
66
|
+
* 原始示例用 Ext 标准组件拼出布局,本组件用完全一致的 Ext 原版 DOM 结构还原:
|
|
67
|
+
* - 容器:x-panel(border 风格两栏);左 Ext.tree.TreePanel(相册树,tbar 放 New Album
|
|
68
|
+
* 按钮、enableDD 树内重排、TreeEditor 内联改名)→ 右 Ext.DataView(图片缩略图网格,
|
|
69
|
+
* DragSelector 橡皮筋框选、Ctrl/Cmd 多选、多选拖拽组合 ghost)。“My Albums / My Images”
|
|
70
|
+
* 均为带标题栏的 x-panel,New Album 落在 tbar(故为扁平工具栏按钮,与原版一致)。
|
|
71
|
+
* - 左/右两栏之间为可拖拽的 x-splitbar(split:true 等价),用原版 SplitBar 组件实现。
|
|
72
|
+
* - 图片网格 DOM 严格对齐原版:div.thumb-wrap > div.thumb > img.thumb-img + span;
|
|
73
|
+
* 选中态加 x-list-selected(复用 Ext.ListView 原生选中类,xtheme-blue/gray 均有主题化规则:
|
|
74
|
+
* blue #dfe8f6 / gray #f0f0f0;零自定义 CSS,蓝/灰原生主题化,与 DataView 一致);框选浮层用
|
|
75
|
+
* 原版 .x-view-selector。相关样式逐字取自 examples/organizer/organizer.css(见 ext-theme.css)。
|
|
76
|
+
* - 跨组件拖拽:原始用 ImageDragZone + TreeDropZone 把图片拖入相册;本组件用 HTML5 拖放
|
|
77
|
+
* (draggable + onDrop 读 data-node-id)实现等价行为:图片(单张或多选)拖到某相册节点即
|
|
78
|
+
* 在该相册下生成对应图片叶子节点。DOM/class 与原版一致,未新增自定义类。
|
|
79
|
+
*/
|
|
80
|
+
export const Organizer = forwardRef<OrganizerHandle, OrganizerProps>(function Organizer(
|
|
81
|
+
{
|
|
82
|
+
albums = [],
|
|
83
|
+
images = [],
|
|
84
|
+
width = 650,
|
|
85
|
+
height = 400,
|
|
86
|
+
newAlbumText = 'New Album',
|
|
87
|
+
albumNamePrefix = 'Album ',
|
|
88
|
+
onAlbumsChange,
|
|
89
|
+
onImageDropped,
|
|
90
|
+
onImagesChange,
|
|
91
|
+
className,
|
|
92
|
+
style,
|
|
93
|
+
},
|
|
94
|
+
ref,
|
|
95
|
+
) {
|
|
96
|
+
const treeRef = useRef<TreePanelHandle>(null);
|
|
97
|
+
const imgScrollRef = useRef<HTMLDivElement>(null);
|
|
98
|
+
const [imgList, setImgList] = useState<OrganizerImage[]>(() => images);
|
|
99
|
+
const [selectedImgs, setSelectedImgs] = useState<Set<string>>(new Set());
|
|
100
|
+
const [selBox, setSelBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
|
101
|
+
const albumCounter = useRef(0);
|
|
102
|
+
const dropHLRef = useRef<HTMLElement | null>(null);
|
|
103
|
+
|
|
104
|
+
// 镜像最新图片列表,供命令式句柄同步读取
|
|
105
|
+
const imgListRef = useRef<OrganizerImage[]>(imgList);
|
|
106
|
+
imgListRef.current = imgList;
|
|
107
|
+
|
|
108
|
+
const notifyAlbums = () => {
|
|
109
|
+
// appendChild/rename 经 setState 异步提交,下一帧读快照才是新值
|
|
110
|
+
requestAnimationFrame(() => onAlbumsChange?.(treeRef.current?.getNodes() ?? []));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const clearDropHL = () => {
|
|
114
|
+
if (dropHLRef.current) {
|
|
115
|
+
dropHLRef.current.style.outline = '';
|
|
116
|
+
dropHLRef.current.style.outlineOffset = '';
|
|
117
|
+
dropHLRef.current = null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleNewAlbum = () => {
|
|
122
|
+
albumCounter.current += 1;
|
|
123
|
+
const id = `album-${Date.now()}-${albumCounter.current}`;
|
|
124
|
+
const text = `${albumNamePrefix}${albumCounter.current}`;
|
|
125
|
+
treeRef.current?.appendChild(null, { id, text, cls: 'album-node', expandable: true });
|
|
126
|
+
notifyAlbums();
|
|
127
|
+
// 等同原始示例:新建后自动进入内联改名
|
|
128
|
+
requestAnimationFrame(() => treeRef.current?.startEdit(id));
|
|
129
|
+
return id;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleRename = () => notifyAlbums();
|
|
133
|
+
|
|
134
|
+
const onTreeDragOver = (e: React.DragEvent) => {
|
|
135
|
+
const el = (e.target as HTMLElement).closest('[data-node-id]') as HTMLElement | null;
|
|
136
|
+
if (!el) return;
|
|
137
|
+
e.preventDefault(); // 允许放置
|
|
138
|
+
if (dropHLRef.current !== el) {
|
|
139
|
+
clearDropHL();
|
|
140
|
+
el.style.outline = '2px solid #316ac5';
|
|
141
|
+
el.style.outlineOffset = '-1px';
|
|
142
|
+
dropHLRef.current = el;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onTreeDragLeave = () => clearDropHL();
|
|
147
|
+
|
|
148
|
+
const onTreeDrop = (e: React.DragEvent) => {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
const el = (e.target as HTMLElement).closest('[data-node-id]') as HTMLElement | null;
|
|
151
|
+
clearDropHL();
|
|
152
|
+
if (!el) return;
|
|
153
|
+
const albumId = el.getAttribute('data-node-id')!;
|
|
154
|
+
let ids: string[] = [];
|
|
155
|
+
const listRaw = e.dataTransfer.getData(ORG_IMAGE_LIST_MIME);
|
|
156
|
+
if (listRaw) {
|
|
157
|
+
try {
|
|
158
|
+
ids = JSON.parse(listRaw);
|
|
159
|
+
} catch {
|
|
160
|
+
ids = [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
164
|
+
const single =
|
|
165
|
+
e.dataTransfer.getData(ORG_IMAGE_MIME) || e.dataTransfer.getData('text/plain');
|
|
166
|
+
if (single) ids = [single];
|
|
167
|
+
}
|
|
168
|
+
const imgs = ids
|
|
169
|
+
.map((id) => imgListRef.current.find((i) => i.id === id))
|
|
170
|
+
.filter((x): x is OrganizerImage => Boolean(x));
|
|
171
|
+
if (imgs.length === 0) return;
|
|
172
|
+
// 等价 ImageDragZone 批量拖拽 → TreeDropZone 批量 append(组合 ghost 一次 drop 多叶子)
|
|
173
|
+
imgs.forEach((img, idx) => {
|
|
174
|
+
treeRef.current?.appendChild(albumId, {
|
|
175
|
+
id: `img-${img.id}-${Date.now()}-${idx}`,
|
|
176
|
+
text: img.name,
|
|
177
|
+
icon: thumbUrl(img),
|
|
178
|
+
cls: 'image-node',
|
|
179
|
+
});
|
|
180
|
+
onImageDropped?.(img, albumId);
|
|
181
|
+
});
|
|
182
|
+
notifyAlbums();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// 橡皮筋框选(等价 DataView + DragSelector)
|
|
186
|
+
const onImgMouseDown = (e: React.MouseEvent) => {
|
|
187
|
+
if (e.button !== 0) return;
|
|
188
|
+
if ((e.target as HTMLElement).closest('.thumb-wrap')) return; // 点中缩略图不框选
|
|
189
|
+
const cont = imgScrollRef.current;
|
|
190
|
+
if (!cont) return;
|
|
191
|
+
const rect = cont.getBoundingClientRect();
|
|
192
|
+
const startX = e.clientX - rect.left + cont.scrollLeft;
|
|
193
|
+
const startY = e.clientY - rect.top + cont.scrollTop;
|
|
194
|
+
const additive = e.ctrlKey || e.metaKey;
|
|
195
|
+
const baseSel = additive ? new Set(selectedImgs) : new Set<string>();
|
|
196
|
+
// 等价 DataView:在空白处按下即清空选择(Ctrl/Cmd 则保留以追加)
|
|
197
|
+
setSelectedImgs(baseSel);
|
|
198
|
+
setSelBox({ x1: startX, y1: startY, x2: startX, y2: startY });
|
|
199
|
+
|
|
200
|
+
const onMove = (ev: MouseEvent) => {
|
|
201
|
+
const r = cont.getBoundingClientRect();
|
|
202
|
+
const cx = ev.clientX - r.left + cont.scrollLeft;
|
|
203
|
+
const cy = ev.clientY - r.top + cont.scrollTop;
|
|
204
|
+
const box = {
|
|
205
|
+
left: Math.min(startX, cx),
|
|
206
|
+
top: Math.min(startY, cy),
|
|
207
|
+
right: Math.max(startX, cx),
|
|
208
|
+
bottom: Math.max(startY, cy),
|
|
209
|
+
};
|
|
210
|
+
setSelBox({ x1: startX, y1: startY, x2: cx, y2: cy });
|
|
211
|
+
const sel = new Set(baseSel);
|
|
212
|
+
cont.querySelectorAll<HTMLElement>('.thumb-wrap').forEach((w) => {
|
|
213
|
+
const wr = w.getBoundingClientRect();
|
|
214
|
+
const wl = wr.left - r.left + cont.scrollLeft;
|
|
215
|
+
const wt = wr.top - r.top + cont.scrollTop;
|
|
216
|
+
const wright = wl + wr.width;
|
|
217
|
+
const wb = wt + wr.height;
|
|
218
|
+
if (wl < box.right && wright > box.left && wt < box.bottom && wb > box.top) {
|
|
219
|
+
const id = w.getAttribute('data-img-id');
|
|
220
|
+
if (id) sel.add(id);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
setSelectedImgs(sel);
|
|
224
|
+
};
|
|
225
|
+
const onUp = () => {
|
|
226
|
+
window.removeEventListener('mousemove', onMove);
|
|
227
|
+
window.removeEventListener('mouseup', onUp);
|
|
228
|
+
setSelBox(null);
|
|
229
|
+
};
|
|
230
|
+
window.addEventListener('mousemove', onMove);
|
|
231
|
+
window.addEventListener('mouseup', onUp);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// 组合 ghost(等价原始 ImageDragZone 多选时的 multi-proxy):复用原生 .x-dd-drag-ghost
|
|
235
|
+
// 中性样式(白底 #fff / 灰边 #ddd/#bbb / 黑字 #000,见 ext-all.css),不写死任何主题色。
|
|
236
|
+
const buildDragImage = (count: number): HTMLElement => {
|
|
237
|
+
const el = document.createElement('div');
|
|
238
|
+
el.className = 'x-dd-drag-ghost';
|
|
239
|
+
el.style.cssText = 'position:absolute;top:-1000px;left:-1000px;font-size:11px;';
|
|
240
|
+
el.textContent = `${count} image${count > 1 ? 's' : ''}`;
|
|
241
|
+
document.body.appendChild(el);
|
|
242
|
+
return el;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const renderImage = (img: OrganizerImage) => {
|
|
246
|
+
const selected = selectedImgs.has(img.id);
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
key={img.id}
|
|
250
|
+
className={['thumb-wrap', selected ? 'x-list-selected' : ''].filter(Boolean).join(' ')}
|
|
251
|
+
data-img-id={img.id}
|
|
252
|
+
draggable
|
|
253
|
+
onDragStart={(e) => {
|
|
254
|
+
let dragged = selectedImgs;
|
|
255
|
+
if (!dragged.has(img.id)) {
|
|
256
|
+
dragged = new Set([img.id]);
|
|
257
|
+
setSelectedImgs(dragged);
|
|
258
|
+
}
|
|
259
|
+
const ids = Array.from(dragged);
|
|
260
|
+
e.dataTransfer.setData(ORG_IMAGE_LIST_MIME, JSON.stringify(ids));
|
|
261
|
+
e.dataTransfer.setData(ORG_IMAGE_MIME, img.id);
|
|
262
|
+
e.dataTransfer.setData('text/plain', img.id);
|
|
263
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
264
|
+
const ghost = buildDragImage(ids.length);
|
|
265
|
+
try {
|
|
266
|
+
e.dataTransfer.setDragImage(ghost, 0, 0);
|
|
267
|
+
} catch {
|
|
268
|
+
/* 某些环境不支持,忽略 */
|
|
269
|
+
}
|
|
270
|
+
setTimeout(() => ghost.remove(), 0);
|
|
271
|
+
}}
|
|
272
|
+
onClick={(e) => {
|
|
273
|
+
if (e.ctrlKey || e.metaKey) {
|
|
274
|
+
setSelectedImgs((prev) => {
|
|
275
|
+
const n = new Set(prev);
|
|
276
|
+
if (n.has(img.id)) n.delete(img.id);
|
|
277
|
+
else n.add(img.id);
|
|
278
|
+
return n;
|
|
279
|
+
});
|
|
280
|
+
} else {
|
|
281
|
+
setSelectedImgs(new Set([img.id]));
|
|
282
|
+
}
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<div className="thumb">
|
|
286
|
+
<img
|
|
287
|
+
className="thumb-img"
|
|
288
|
+
src={thumbUrl(img)}
|
|
289
|
+
alt={img.name}
|
|
290
|
+
draggable={false}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
<span>{img.name}</span>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
useImperativeHandle(ref, () => ({
|
|
299
|
+
addAlbum: (text?: string) => {
|
|
300
|
+
albumCounter.current += 1;
|
|
301
|
+
const id = `album-${Date.now()}-${albumCounter.current}`;
|
|
302
|
+
const t = text ?? `${albumNamePrefix}${albumCounter.current}`;
|
|
303
|
+
treeRef.current?.appendChild(null, { id, text: t, cls: 'album-node', expandable: true });
|
|
304
|
+
notifyAlbums();
|
|
305
|
+
return id;
|
|
306
|
+
},
|
|
307
|
+
addImage: (image: OrganizerImage) => {
|
|
308
|
+
setImgList((prev) => [...prev, image]);
|
|
309
|
+
onImagesChange?.(imgListRef.current);
|
|
310
|
+
},
|
|
311
|
+
getAlbums: () => treeRef.current?.getNodes() ?? [],
|
|
312
|
+
getImages: () => imgListRef.current,
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<BorderLayout
|
|
317
|
+
width={width}
|
|
318
|
+
height={height}
|
|
319
|
+
className={['x-organizer', className].filter(Boolean).join(' ')}
|
|
320
|
+
style={style}
|
|
321
|
+
regions={[
|
|
322
|
+
{
|
|
323
|
+
// 左:相册树(等价原版 Ext.tree.TreePanel,region:'west',tbar 挂 New Album,
|
|
324
|
+
// split:true 可拖拽,margins '5 0 5 5',整组由 BorderLayout 零额外 CSS 还原外框/底/边距/分隔条)
|
|
325
|
+
region: 'west',
|
|
326
|
+
width: 200,
|
|
327
|
+
split: true,
|
|
328
|
+
minSize: 120,
|
|
329
|
+
maxSize: 400,
|
|
330
|
+
title: 'My Albums',
|
|
331
|
+
margins: '5 0 5 5',
|
|
332
|
+
tbar: (
|
|
333
|
+
<Toolbar
|
|
334
|
+
items={[{ text: newAlbumText, iconCls: 'album-btn', handler: handleNewAlbum }]}
|
|
335
|
+
/>
|
|
336
|
+
),
|
|
337
|
+
children: (
|
|
338
|
+
<div
|
|
339
|
+
style={{ minHeight: '100%', position: 'relative' }}
|
|
340
|
+
onDragOver={onTreeDragOver}
|
|
341
|
+
onDragLeave={onTreeDragLeave}
|
|
342
|
+
onDrop={onTreeDrop}
|
|
343
|
+
>
|
|
344
|
+
<TreePanel
|
|
345
|
+
ref={treeRef}
|
|
346
|
+
nodes={albums}
|
|
347
|
+
rootVisible={false}
|
|
348
|
+
editable
|
|
349
|
+
enableNodeReorder
|
|
350
|
+
onRename={handleRename}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
// 右:图片网格(等价原版 Ext.Panel,region:'center',layout:'fit',body 即 DataView;
|
|
357
|
+
// margins '5 5 5 0',#images 渐变底 + 缩略图网格,框选/多选拖拽逻辑不变)
|
|
358
|
+
region: 'center',
|
|
359
|
+
title: 'My Images',
|
|
360
|
+
margins: '5 5 5 0',
|
|
361
|
+
children: (
|
|
362
|
+
<div
|
|
363
|
+
id="images"
|
|
364
|
+
ref={imgScrollRef}
|
|
365
|
+
data-org="img-scroll"
|
|
366
|
+
style={{
|
|
367
|
+
height: '100%',
|
|
368
|
+
overflow: 'auto',
|
|
369
|
+
padding: 0,
|
|
370
|
+
position: 'relative',
|
|
371
|
+
}}
|
|
372
|
+
onMouseDown={onImgMouseDown}
|
|
373
|
+
>
|
|
374
|
+
{imgList.length === 0 ? (
|
|
375
|
+
<div style={{ padding: '10px 8px', color: '#666', fontSize: 12 }}>No images</div>
|
|
376
|
+
) : (
|
|
377
|
+
<div style={{ overflow: 'hidden', position: 'relative' }}>{imgList.map(renderImage)}</div>
|
|
378
|
+
)}
|
|
379
|
+
{selBox && (
|
|
380
|
+
<div
|
|
381
|
+
className="x-view-selector"
|
|
382
|
+
style={{
|
|
383
|
+
position: 'absolute',
|
|
384
|
+
zIndex: 5,
|
|
385
|
+
pointerEvents: 'none',
|
|
386
|
+
left: Math.min(selBox.x1, selBox.x2),
|
|
387
|
+
top: Math.min(selBox.y1, selBox.y2),
|
|
388
|
+
width: Math.abs(selBox.x2 - selBox.x1),
|
|
389
|
+
height: Math.abs(selBox.y2 - selBox.y1),
|
|
390
|
+
borderWidth: 1,
|
|
391
|
+
borderStyle: 'solid',
|
|
392
|
+
}}
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
),
|
|
397
|
+
},
|
|
398
|
+
]}
|
|
399
|
+
/>
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
Organizer.displayName = 'Organizer';
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
|
|
2
|
+
import { Toolbar, type ToolbarItem } from './Toolbar';
|
|
3
|
+
import { Slider } from './Slider';
|
|
4
|
+
import { ProgressBar } from './ProgressBar';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PagingToolbar —— 保真复刻 Ext.PagingToolbar(src/widgets/PagingToolbar.js)。
|
|
8
|
+
*
|
|
9
|
+
* 保真契约(DOM/class 严格对齐 Ext 运行时渲染,已通过 dump golden 校验):
|
|
10
|
+
* - 本质是带固定 items 的 Ext.Toolbar(继承 Toolbar,根类 x-toolbar x-small-editor x-toolbar-layout-ct)
|
|
11
|
+
* - item 序列(getPagingItems,200-250 行):
|
|
12
|
+
* first(x-tbar-page-first, disabled@page1)
|
|
13
|
+
* prev (x-tbar-page-prev, disabled@page1)
|
|
14
|
+
* '-' 分隔符
|
|
15
|
+
* beforePageText(默认 'Page',TextItem → div.xtb-text)
|
|
16
|
+
* <input> 页码框(cls x-tbar-page-number;golden 为 Ext.NumberField,
|
|
17
|
+
* 类 x-form-text x-form-field x-form-num-field x-tbar-page-number,无 inline 宽高,由 CSS 驱动)
|
|
18
|
+
* afterPageText(默认 'of {0}',本例 'of 5',TextItem → div.xtb-text)
|
|
19
|
+
* '-'
|
|
20
|
+
* next(x-tbar-page-next)
|
|
21
|
+
* last(x-tbar-page-last)
|
|
22
|
+
* '-'
|
|
23
|
+
* refresh(x-tbar-loading)
|
|
24
|
+
* - displayInfo=true 时追加 '->'(fill)+ TextItem(displayMsg,落在右侧行)
|
|
25
|
+
* - 导航按钮为图标按钮:iconCls 经 Ext 落在 <button>(带 x-btn-text),em 仅承载箭头类;
|
|
26
|
+
* 禁用态加 x-item-disabled(Ext.Button.disabledClass 默认),非原生 disabled。
|
|
27
|
+
*
|
|
28
|
+
* 注意:golden 用 MemoryProxy 全量加载(未真正分页),故 displayMsg 的 end 恒为 total。
|
|
29
|
+
* 此处按 golden 同样公式复刻:(page-1)*pageSize + total,使 page1 输出 "1 - 50 of 50"。
|
|
30
|
+
*
|
|
31
|
+
* 行为契约(最小可用集,非完整 store 联动):
|
|
32
|
+
* - 点击 first/prev/next/last/refresh 切换 activePage;first/prev 在 page1 禁用、next/last 在末页禁用。
|
|
33
|
+
*/
|
|
34
|
+
export interface PagingToolbarProps {
|
|
35
|
+
/** 每页条数,等价 Ext pageSize(默认 20) */
|
|
36
|
+
pageSize?: number;
|
|
37
|
+
/** 总记录数,等价 store.getTotalCount() */
|
|
38
|
+
total?: number;
|
|
39
|
+
/** 初始 1-based 页码(默认 1) */
|
|
40
|
+
activePage?: number;
|
|
41
|
+
/** 是否显示右侧 displayMsg(等价 displayInfo) */
|
|
42
|
+
displayInfo?: boolean;
|
|
43
|
+
/** displayMsg 模板,{0}=start {1}=end {2}=total */
|
|
44
|
+
displayMsg?: string;
|
|
45
|
+
/** 无数据时文本 */
|
|
46
|
+
emptyMsg?: string;
|
|
47
|
+
/** 页码框前文本,等价 beforePageText */
|
|
48
|
+
beforePageText?: string;
|
|
49
|
+
/** 页码框后文本模板,等价 afterPageText('{0}'=总页数) */
|
|
50
|
+
afterPageText?: string;
|
|
51
|
+
width?: number | string;
|
|
52
|
+
className?: string;
|
|
53
|
+
style?: React.CSSProperties;
|
|
54
|
+
/** 页码变化回调(等价 change 事件) */
|
|
55
|
+
onPageChange?: (page: number) => void;
|
|
56
|
+
/** 运行时总记录数变化回调(等价 load 后 store 变动) */
|
|
57
|
+
onTotalChange?: (total: number) => void;
|
|
58
|
+
/** 启用 SlidingPager 插件:用可拖动滑块替换页码输入框(等价 Ext.ux.SlidingPager)。
|
|
59
|
+
* 隐藏 beforePageText/页码框/afterPageText,插入 width:114、minValue:1、maxValue=总页数 的 Slider,
|
|
60
|
+
* 拖动 changecomplete 触发翻页,total/pages 变化时同步 maxValue 与当前值。 */
|
|
61
|
+
slidingPager?: boolean;
|
|
62
|
+
/** 启用 ProgressBarPager 插件(等价 Ext.ux.ProgressBarPager):仅当 displayInfo 为真时生效,
|
|
63
|
+
* 用 ProgressBar 替换右侧 displayMsg 文本,填充比例 = page/pages,文案仍为 displayMsg;
|
|
64
|
+
* 点击进度条按横向位置跳转到对应页码。 */
|
|
65
|
+
progressBarPager?: boolean;
|
|
66
|
+
/** ProgressBar 宽度,等价 Ext.ux.ProgressBarPager.progBarWidth(默认 225)。 */
|
|
67
|
+
progBarWidth?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 命令式句柄:运行时动态分页 / 重载数据 */
|
|
71
|
+
export interface PagingToolbarHandle {
|
|
72
|
+
/** 跳转到指定 1-based 页码(自动夹取到 [1, pages]) */
|
|
73
|
+
setPage: (p: number) => void;
|
|
74
|
+
next: () => void;
|
|
75
|
+
prev: () => void;
|
|
76
|
+
first: () => void;
|
|
77
|
+
last: () => void;
|
|
78
|
+
/** 刷新当前页(等价 reload 同页) */
|
|
79
|
+
refresh: () => void;
|
|
80
|
+
/** 运行时重载数据:设置新 total,页数随之重算,当前页夹取到有效范围 */
|
|
81
|
+
setTotal: (total: number) => void;
|
|
82
|
+
/** 读取当前页码 */
|
|
83
|
+
getPage: () => number;
|
|
84
|
+
/** 读取当前总记录数 */
|
|
85
|
+
getTotal: () => number;
|
|
86
|
+
/** 读取当前总页数 */
|
|
87
|
+
getPages: () => number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const PagingToolbar = forwardRef<PagingToolbarHandle, PagingToolbarProps>(
|
|
91
|
+
(
|
|
92
|
+
{
|
|
93
|
+
pageSize = 20,
|
|
94
|
+
total = 0,
|
|
95
|
+
activePage = 1,
|
|
96
|
+
displayInfo = false,
|
|
97
|
+
displayMsg = 'Displaying {0} - {1} of {2}',
|
|
98
|
+
emptyMsg = 'No data to display',
|
|
99
|
+
beforePageText = 'Page',
|
|
100
|
+
afterPageText = 'of {0}',
|
|
101
|
+
width,
|
|
102
|
+
className,
|
|
103
|
+
style,
|
|
104
|
+
onPageChange,
|
|
105
|
+
onTotalChange,
|
|
106
|
+
slidingPager = false,
|
|
107
|
+
progressBarPager = false,
|
|
108
|
+
progBarWidth = 225,
|
|
109
|
+
},
|
|
110
|
+
ref,
|
|
111
|
+
) => {
|
|
112
|
+
// 双轨:total 名义来源 = prop,运行时可被 setTotal 改写(dynTotal)
|
|
113
|
+
const [dynTotal, setDynTotal] = useState(total);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setDynTotal(total);
|
|
116
|
+
}, [total]);
|
|
117
|
+
|
|
118
|
+
const pages = dynTotal <= 0 ? 0 : Math.ceil(dynTotal / pageSize);
|
|
119
|
+
const [page, setPageState] = useState(activePage);
|
|
120
|
+
|
|
121
|
+
const go = useCallback(
|
|
122
|
+
(p: number) => {
|
|
123
|
+
const max = Math.max(pages, 1);
|
|
124
|
+
const np = Math.max(1, Math.min(p, max));
|
|
125
|
+
setPageState(np);
|
|
126
|
+
onPageChange?.(np);
|
|
127
|
+
},
|
|
128
|
+
[pages, onPageChange],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const pageRef = useRef(page);
|
|
132
|
+
pageRef.current = page;
|
|
133
|
+
const totalRef = useRef(dynTotal);
|
|
134
|
+
totalRef.current = dynTotal;
|
|
135
|
+
const pagesRef = useRef(pages);
|
|
136
|
+
pagesRef.current = pages;
|
|
137
|
+
|
|
138
|
+
useImperativeHandle(
|
|
139
|
+
ref,
|
|
140
|
+
(): PagingToolbarHandle => ({
|
|
141
|
+
setPage: (p: number) => go(p),
|
|
142
|
+
next: () => go(pageRef.current + 1),
|
|
143
|
+
prev: () => go(pageRef.current - 1),
|
|
144
|
+
first: () => go(1),
|
|
145
|
+
last: () => go(pagesRef.current),
|
|
146
|
+
refresh: () => go(pageRef.current),
|
|
147
|
+
setTotal: (t: number) => {
|
|
148
|
+
setDynTotal(t);
|
|
149
|
+
onTotalChange?.(t);
|
|
150
|
+
// 页数变化后当前页可能越界,夹取到有效范围
|
|
151
|
+
const np = Math.max(1, Math.min(pageRef.current, Math.max(Math.ceil(t / pageSize), 1)));
|
|
152
|
+
setPageState(np);
|
|
153
|
+
onPageChange?.(np);
|
|
154
|
+
},
|
|
155
|
+
getPage: () => pageRef.current,
|
|
156
|
+
getTotal: () => totalRef.current,
|
|
157
|
+
getPages: () => pagesRef.current,
|
|
158
|
+
}),
|
|
159
|
+
[go, pageSize, onPageChange, onTotalChange],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const pageInput = (
|
|
163
|
+
<input
|
|
164
|
+
key="pn"
|
|
165
|
+
type="text"
|
|
166
|
+
className="x-form-text x-form-field x-form-num-field x-tbar-page-number"
|
|
167
|
+
value={String(page)}
|
|
168
|
+
onChange={(e) => {
|
|
169
|
+
const v = parseInt(e.target.value, 10);
|
|
170
|
+
if (!Number.isNaN(v)) go(v);
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// SlidingPager 插件:用 Slider 替换页码输入框(对齐 Ext.ux.SlidingPager.init)。
|
|
176
|
+
// 原始隐藏 inputItem 前后各 2 个 item(2 分隔符 + beforePageText + input + afterPageText),
|
|
177
|
+
// 并在 idx+1 插入 Slider;可见形态即 first/prev/slider/next/last/refresh。
|
|
178
|
+
const slidingItem = (
|
|
179
|
+
<Slider
|
|
180
|
+
key="sliding-pager"
|
|
181
|
+
width={114}
|
|
182
|
+
minValue={1}
|
|
183
|
+
maxValue={Math.max(pages, 1)}
|
|
184
|
+
value={page}
|
|
185
|
+
// 对齐 Ext.ux.SlidingPager.init → 给 Slider 挂 Ext.slider.Tip,getText: 'Page <b>{0}</b> of <b>{1}</b>'
|
|
186
|
+
getTipText={(v, max) => `Page <b>${v}</b> of <b>${max}</b>`}
|
|
187
|
+
onChangeComplete={(v) => go(v)}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const items: ToolbarItem[] = slidingPager
|
|
192
|
+
? [
|
|
193
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-first', disabled: page <= 1, onClick: () => go(1) },
|
|
194
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-prev', disabled: page <= 1, onClick: () => go(page - 1) },
|
|
195
|
+
slidingItem,
|
|
196
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-next', disabled: page >= pages, onClick: () => go(page + 1) },
|
|
197
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-last', disabled: page >= pages, onClick: () => go(pages) },
|
|
198
|
+
'-',
|
|
199
|
+
{ xtype: 'button', iconCls: 'x-tbar-loading', onClick: () => go(page) },
|
|
200
|
+
]
|
|
201
|
+
: [
|
|
202
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-first', disabled: page <= 1, onClick: () => go(1) },
|
|
203
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-prev', disabled: page <= 1, onClick: () => go(page - 1) },
|
|
204
|
+
'-',
|
|
205
|
+
beforePageText,
|
|
206
|
+
pageInput,
|
|
207
|
+
afterPageText.replace('{0}', String(pages)),
|
|
208
|
+
'-',
|
|
209
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-next', disabled: page >= pages, onClick: () => go(page + 1) },
|
|
210
|
+
{ xtype: 'button', iconCls: 'x-tbar-page-last', disabled: page >= pages, onClick: () => go(pages) },
|
|
211
|
+
'-',
|
|
212
|
+
{ xtype: 'button', iconCls: 'x-tbar-loading', onClick: () => go(page) },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
if (displayInfo) {
|
|
216
|
+
const start = dynTotal === 0 ? 0 : (page - 1) * pageSize + 1;
|
|
217
|
+
// 复刻 golden 全量加载的 end 公式:(page-1)*pageSize + total
|
|
218
|
+
const end = dynTotal === 0 ? 0 : (page - 1) * pageSize + dynTotal;
|
|
219
|
+
const msg =
|
|
220
|
+
dynTotal === 0
|
|
221
|
+
? emptyMsg
|
|
222
|
+
: displayMsg
|
|
223
|
+
.replace('{0}', String(start))
|
|
224
|
+
.replace('{1}', String(end))
|
|
225
|
+
.replace('{2}', String(dynTotal));
|
|
226
|
+
items.push('->');
|
|
227
|
+
// ProgressBarPager:用 ProgressBar 替换纯文本 displayMsg(value/page 由点击/翻页实时联动)。
|
|
228
|
+
// 非 progressBarPager 时保持原始 xtb-text 文本。
|
|
229
|
+
items.push(
|
|
230
|
+
progressBarPager ? (
|
|
231
|
+
<ProgressBar
|
|
232
|
+
key="progress-pager-rt"
|
|
233
|
+
width={progBarWidth}
|
|
234
|
+
value={pages > 0 ? page / pages : 0}
|
|
235
|
+
text={msg}
|
|
236
|
+
onClick={(e) => {
|
|
237
|
+
if (pages <= 0) return;
|
|
238
|
+
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
239
|
+
const position = e.clientX - box.x;
|
|
240
|
+
const newpage = Math.ceil(position / (box.width / pages));
|
|
241
|
+
go(newpage);
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
) : (
|
|
245
|
+
{ xtype: 'tbtext', text: msg }
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return <Toolbar width={width} className={className} style={style} items={items} />;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
(PagingToolbar as unknown as { displayName?: string }).displayName = 'PagingToolbar';
|
|
254
|
+
|
|
255
|
+
export default PagingToolbar;
|