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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +36 -0
  3. package/src/components/AbsoluteLayout.tsx +206 -0
  4. package/src/components/Accordion.tsx +266 -0
  5. package/src/components/AnchorLayout.tsx +131 -0
  6. package/src/components/BorderLayout.tsx +512 -0
  7. package/src/components/Box.tsx +289 -0
  8. package/src/components/Button.tsx +300 -0
  9. package/src/components/ButtonGroup.tsx +170 -0
  10. package/src/components/CalendarPicker.tsx +431 -0
  11. package/src/components/CardLayout.tsx +122 -0
  12. package/src/components/CenterLayout.tsx +95 -0
  13. package/src/components/CheckboxGroup.tsx +200 -0
  14. package/src/components/ColorMenu.tsx +195 -0
  15. package/src/components/ColorPalette.tsx +126 -0
  16. package/src/components/ColumnLayout.tsx +183 -0
  17. package/src/components/ColumnTree.tsx +383 -0
  18. package/src/components/CycleButton.tsx +302 -0
  19. package/src/components/DataView.tsx +244 -0
  20. package/src/components/DataViewMore.tsx +337 -0
  21. package/src/components/DataViewTransition.tsx +235 -0
  22. package/src/components/DateMenu.tsx +163 -0
  23. package/src/components/DatePicker.tsx +387 -0
  24. package/src/components/DisplayField.tsx +73 -0
  25. package/src/components/Editor.tsx +214 -0
  26. package/src/components/EditorGrid.tsx +361 -0
  27. package/src/components/FieldLabeler.tsx +81 -0
  28. package/src/components/FieldReplicator.tsx +135 -0
  29. package/src/components/FieldSet.tsx +149 -0
  30. package/src/components/FitLayout.tsx +76 -0
  31. package/src/components/Form.tsx +3484 -0
  32. package/src/components/Grid.tsx +2173 -0
  33. package/src/components/GridFilters.tsx +332 -0
  34. package/src/components/GroupTab.tsx +274 -0
  35. package/src/components/HtmlEditor.tsx +391 -0
  36. package/src/components/ImageOrganizer.tsx +984 -0
  37. package/src/components/ImageOrganizerService.ts +361 -0
  38. package/src/components/ListView.tsx +221 -0
  39. package/src/components/LoadMask.tsx +74 -0
  40. package/src/components/Menu.tsx +227 -0
  41. package/src/components/MessageBox.tsx +138 -0
  42. package/src/components/MultiSlider.tsx +369 -0
  43. package/src/components/Organizer.tsx +403 -0
  44. package/src/components/PagingToolbar.tsx +255 -0
  45. package/src/components/Panel.tsx +451 -0
  46. package/src/components/ProgressBar.tsx +127 -0
  47. package/src/components/PropertyGrid.tsx +142 -0
  48. package/src/components/QuickTip.tsx +174 -0
  49. package/src/components/Resizable.tsx +241 -0
  50. package/src/components/RowLayout.tsx +126 -0
  51. package/src/components/Slider.tsx +353 -0
  52. package/src/components/SliderField.tsx +206 -0
  53. package/src/components/SpinnerField.tsx +141 -0
  54. package/src/components/SplitBar.tsx +215 -0
  55. package/src/components/Spotlight.tsx +262 -0
  56. package/src/components/StatusBar.tsx +118 -0
  57. package/src/components/TabPanel.tsx +540 -0
  58. package/src/components/TableLayout.tsx +132 -0
  59. package/src/components/ThemeSwitcher.tsx +167 -0
  60. package/src/components/ToolTip.tsx +226 -0
  61. package/src/components/Toolbar.tsx +321 -0
  62. package/src/components/Tree.tsx +809 -0
  63. package/src/components/TreeGrid.tsx +418 -0
  64. package/src/components/Viewport.tsx +218 -0
  65. package/src/components/Window.tsx +441 -0
  66. package/src/components/Writer.tsx +303 -0
  67. package/src/components/XmlTreeLoader.tsx +233 -0
  68. package/src/components/dnd/DndContext.tsx +328 -0
  69. package/src/components/dnd/DragSource.tsx +202 -0
  70. package/src/components/dnd/DragZone.tsx +66 -0
  71. package/src/components/dnd/DropTarget.tsx +86 -0
  72. package/src/components/dnd/DropZone.tsx +166 -0
  73. package/src/components/dnd/PanelProxy.tsx +81 -0
  74. package/src/components/dnd/PanelResizer.tsx +143 -0
  75. package/src/components/dnd/Portal.tsx +254 -0
  76. package/src/components/dnd/Reorderer.tsx +73 -0
  77. package/src/components/dnd/ToolbarReorderer.tsx +226 -0
  78. package/src/main.tsx +2649 -0
  79. package/src/styles/ext-theme.css +889 -0
  80. package/src/util/theme.ts +95 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Image Organizer 服务端能力模拟层(Mock Backend / Service Layer)。
3
+ *
4
+ * 原始 `examples/image-organizer` 是一个完整的服务端应用:PHP + SQLite,通过
5
+ * Ext.Direct(命名空间 `Imgorg.ss`)暴露 `Albums` / `Images` / `Tags` 三组 action。
6
+ * 本文件用内存数组模拟这套 SQLite 表,并以**完全一致的异步 Promise 接口**暴露,
7
+ * 使得前端组件无需关心数据来自内存还是真实 HTTP 后端——日后只需提供一个实现了
8
+ * `ImageOrganizerService` 接口的实例(例如基于 fetch/axios 的 HTTP 实现),
9
+ * 即可无缝对接真实后端,组件层零改动。
10
+ *
11
+ * ┌─ 原始 Ext.Direct API(php/config.php) ───────────────────────────────┐
12
+ * │ Imgorg.ss.Albums.loadtree() → getAlbums() │
13
+ * │ Imgorg.ss.Albums.addOrUpdate(d) → createAlbum() / renameAlbum() │
14
+ * │ Imgorg.ss.Albums.remove(d) → deleteAlbum() │
15
+ * │ Imgorg.ss.Albums.getAllInfo(d) → getAlbumSummaries() │
16
+ * │ Imgorg.ss.Albums.getAlbums({image})→ getAlbums() + 前端过滤 │
17
+ * │ Imgorg.ss.Images.load({tags,album})→ getImages({albumId,tags}) │
18
+ * │ Imgorg.ss.Images.upload(d,files) → uploadImage(file) │
19
+ * │ Imgorg.ss.Images.addToAlbum(d) → addImagesToAlbum() │
20
+ * │ Imgorg.ss.Images.tagImage(d) → tagImage() │
21
+ * │ Imgorg.ss.Images.rename(d) → renameImage() │
22
+ * │ Imgorg.ss.Images.remove(d) → deleteImages() │
23
+ * │ Imgorg.ss.Images.getInfo(d) → getImageInfo() │
24
+ * │ Imgorg.ss.Tags.load({query}) → getTags(query) │
25
+ * │ Imgorg.ss.Tags.getTags({image}) → getTagsForImage(image) │
26
+ * └───────────────────────────────────────────────────────────────────────┘
27
+ *
28
+ * 与原始 schema 的取舍:原始 Images 表只有单列 `album_id`(一张图只属一个相册),
29
+ * 而本 React UI 支持「同一张图拖入多个相册」(多对多)。因此本 mock 用
30
+ * `albumImages: Map<albumId, Set<imageId>>` 表达分组关系,比原 schema 更贴合 UI,
31
+ * 同时真实后端也可用中间表(album_images)等价实现。
32
+ */
33
+
34
+ export interface ImgItem {
35
+ id: string;
36
+ name: string;
37
+ /** 缩略图底色(无真实图片时用色块近似,等价 ext thumb 背景 #dddddd) */
38
+ color?: string;
39
+ /** 缩略图地址(可选;提供则渲染 <img>,否则用内联 SVG 自包含缩略图) */
40
+ url?: string;
41
+ /** 字节大小(File Info 展示用) */
42
+ size?: number;
43
+ width?: number;
44
+ height?: number;
45
+ tags?: string[];
46
+ }
47
+
48
+ export interface AlbumModel {
49
+ id: string;
50
+ name: string;
51
+ /** 该相册包含的全部图片 id(等价原始 albums 子节点映射) */
52
+ imageIds: string[];
53
+ /** 封面图片 id(画廊取首图作为封面) */
54
+ coverId?: string;
55
+ }
56
+
57
+ export interface TagModel {
58
+ id: string;
59
+ text: string;
60
+ }
61
+
62
+ export interface ImageInfo {
63
+ id: string;
64
+ name: string;
65
+ size?: number;
66
+ width?: number;
67
+ height?: number;
68
+ tags: string[];
69
+ /** 该图片所属相册的名称列表(等价原始 Albums.getAlbums) */
70
+ albums: string[];
71
+ }
72
+
73
+ /** 等价原始 getAlbumSummaries / getAllInfo:带图片数的相册(画廊用) */
74
+ export interface AlbumSummary extends AlbumModel {
75
+ count: number;
76
+ }
77
+
78
+ /**
79
+ * 服务端契约。组件只依赖此接口,不关心底层是内存 mock 还是真实 HTTP。
80
+ * 所有方法返回 Promise,模拟网络往返;实现可注入(见 `service` prop)。
81
+ */
82
+ export interface ImageOrganizerService {
83
+ // ============================ Albums ============================
84
+ /** 等价 Albums.loadtree / getAlbums:返回全部相册及分组关系 */
85
+ getAlbums(): Promise<AlbumModel[]>;
86
+ /** 等价 Albums.addOrUpdate(新增):创建相册,id 可由调用方指定(mock 自动分配) */
87
+ createAlbum(input: { id?: string; name: string }): Promise<AlbumModel>;
88
+ /** 等价 Albums.addOrUpdate(更新):相册改名 */
89
+ renameAlbum(id: string, name: string): Promise<AlbumModel>;
90
+ /** 等价 Albums.remove:删除相册及其分组关系(不删图片本体) */
91
+ deleteAlbum(id: string): Promise<void>;
92
+ /** 等价 Albums.getAllInfo:返回带图片数的相册(画廊用) */
93
+ getAlbumSummaries(): Promise<AlbumSummary[]>;
94
+
95
+ // ============================ Images ============================
96
+ /** 等价 Images.load:按条件查询图片(albumId / tags 过滤) */
97
+ getImages(query?: { albumId?: string; tags?: string[] }): Promise<ImgItem[]>;
98
+ /** 等价 Images.upload:上传文件(mock 内用 objectURL 模拟落地) */
99
+ uploadImage(file: File, meta?: Partial<ImgItem>): Promise<ImgItem>;
100
+ /** 等价 Images.addToAlbum:将图片加入相册(幂等去重) */
101
+ addImagesToAlbum(imageIds: string[], albumId: string): Promise<void>;
102
+ /** 从相册移除分组关系(不删图片本体) */
103
+ removeImagesFromAlbum(imageIds: string[], albumId: string): Promise<void>;
104
+ /** 等价 Images.rename:改名(file move 未模拟,仅改记录名/url) */
105
+ renameImage(id: string, name: string): Promise<ImgItem>;
106
+ /** 等价 Images.remove:删除图片本体及所有分组/标签关系 */
107
+ deleteImages(ids: string[]): Promise<void>;
108
+ /** 等价 Images.getInfo:图片元数据 / exif */
109
+ getImageInfo(id: string): Promise<ImageInfo>;
110
+
111
+ // ============================ Tags ============================
112
+ /** 等价 Tags.load:标签检索(type-ahead) */
113
+ getTags(query?: string): Promise<TagModel[]>;
114
+ /** 等价 Tags.getTags:某图片的标签 */
115
+ getTagsForImage(imageId: string): Promise<TagModel[]>;
116
+ /** 等价 Images.tagImage:给图片打标签(标签不存在则新建) */
117
+ tagImage(imageIds: string[], tag: string): Promise<void>;
118
+ /** 移除图片的某个标签 */
119
+ untagImage(imageId: string, tag: string): Promise<void>;
120
+ }
121
+
122
+ export interface MockSeed {
123
+ albums?: AlbumModel[];
124
+ images?: ImgItem[];
125
+ tags?: TagModel[];
126
+ }
127
+
128
+ const delay = (ms: number) => (ms > 0 ? new Promise<void>((r) => setTimeout(r, ms)) : Promise.resolve());
129
+
130
+ /**
131
+ * 内存版服务端实现:用 Map/Set 模拟 SQLite 表。
132
+ * - albums: id → {id,name}
133
+ * - images: id → ImgItem
134
+ * - albumImages: albumId → Set<imageId>
135
+ * - tags: id → text
136
+ * - imageTags: imageId → Set<tagId>
137
+ */
138
+ export class MockImageOrganizerService implements ImageOrganizerService {
139
+ private albums = new Map<string, { id: string; name: string }>();
140
+ private images = new Map<string, ImgItem>();
141
+ private albumImages = new Map<string, Set<string>>();
142
+ private tags = new Map<string, string>(); // id → text
143
+ private imageTags = new Map<string, Set<string>>(); // imageId → Set<tagId>
144
+ private albumSeq = 0;
145
+ private imageSeq = 0;
146
+ private tagSeq = 0;
147
+ /** 模拟网络延迟(毫秒),默认 0(即时)。真实后端可视为 RTT。 */
148
+ readonly latency: number;
149
+
150
+ constructor(seed?: MockSeed, latency = 0) {
151
+ this.latency = latency;
152
+ (seed?.tags ?? []).forEach((t) => this.tags.set(t.id, t.text));
153
+ (seed?.images ?? []).forEach((img) => {
154
+ this.images.set(img.id, { ...img });
155
+ (img.tags ?? []).forEach((txt) => {
156
+ const tid = this.ensureTag(txt);
157
+ this.addImageTag(img.id, tid);
158
+ });
159
+ });
160
+ (seed?.albums ?? []).forEach((a) => {
161
+ this.albums.set(a.id, { id: a.id, name: a.name });
162
+ const set = new Set<string>();
163
+ a.imageIds.forEach((id) => {
164
+ if (this.images.has(id)) set.add(id);
165
+ });
166
+ this.albumImages.set(a.id, set);
167
+ });
168
+ }
169
+
170
+ private ensureTag(text: string): string {
171
+ for (const [id, t] of this.tags) if (t === text) return id;
172
+ const id = `tag-${++this.tagSeq}`;
173
+ this.tags.set(id, text);
174
+ return id;
175
+ }
176
+
177
+ private addImageTag(imageId: string, tagId: string) {
178
+ if (!this.imageTags.has(imageId)) this.imageTags.set(imageId, new Set());
179
+ this.imageTags.get(imageId)!.add(tagId);
180
+ }
181
+
182
+ private async readDimensions(url?: string): Promise<{ width?: number; height?: number }> {
183
+ if (!url || url.startsWith('data:')) return {};
184
+ return new Promise((resolve) => {
185
+ const img = new Image();
186
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
187
+ img.onerror = () => resolve({});
188
+ img.src = url;
189
+ });
190
+ }
191
+
192
+ // ---------------------------- Albums ----------------------------
193
+ async getAlbums(): Promise<AlbumModel[]> {
194
+ await delay(this.latency);
195
+ return Array.from(this.albums.values()).map((a) => ({
196
+ id: a.id,
197
+ name: a.name,
198
+ imageIds: Array.from(this.albumImages.get(a.id) ?? []),
199
+ }));
200
+ }
201
+
202
+ async createAlbum(input: { id?: string; name: string }): Promise<AlbumModel> {
203
+ await delay(this.latency);
204
+ const id = input.id ?? `album-${++this.albumSeq}-${Date.now()}`;
205
+ this.albums.set(id, { id, name: input.name });
206
+ if (!this.albumImages.has(id)) this.albumImages.set(id, new Set());
207
+ return { id, name: input.name, imageIds: [] };
208
+ }
209
+
210
+ async renameAlbum(id: string, name: string): Promise<AlbumModel> {
211
+ await delay(this.latency);
212
+ const a = this.albums.get(id);
213
+ if (!a) throw new Error(`Album not found: ${id}`);
214
+ a.name = name;
215
+ return { id, name, imageIds: Array.from(this.albumImages.get(id) ?? []) };
216
+ }
217
+
218
+ async deleteAlbum(id: string): Promise<void> {
219
+ await delay(this.latency);
220
+ this.albums.delete(id);
221
+ this.albumImages.delete(id);
222
+ }
223
+
224
+ async getAlbumSummaries(): Promise<AlbumSummary[]> {
225
+ await delay(this.latency);
226
+ return Array.from(this.albums.values()).map((a) => {
227
+ const ids = Array.from(this.albumImages.get(a.id) ?? []);
228
+ return { id: a.id, name: a.name, imageIds: ids, count: ids.length };
229
+ });
230
+ }
231
+
232
+ // ---------------------------- Images ----------------------------
233
+ async getImages(query?: { albumId?: string; tags?: string[] }): Promise<ImgItem[]> {
234
+ await delay(this.latency);
235
+ let list = Array.from(this.images.values());
236
+ if (query?.albumId) {
237
+ const set = this.albumImages.get(query.albumId) ?? new Set();
238
+ list = list.filter((i) => set.has(i.id));
239
+ }
240
+ if (query?.tags && query.tags.length) {
241
+ list = list.filter((i) => {
242
+ const myTags = this.imageTags.get(i.id);
243
+ if (!myTags) return false;
244
+ const texts = Array.from(myTags).map((tid) => this.tags.get(tid) ?? '');
245
+ return query.tags!.every((t) => texts.includes(t));
246
+ });
247
+ }
248
+ return list.map((i) => ({ ...i, tags: [...(i.tags ?? [])] }));
249
+ }
250
+
251
+ async uploadImage(file: File, meta?: Partial<ImgItem>): Promise<ImgItem> {
252
+ await delay(this.latency);
253
+ const id = `up-${++this.imageSeq}-${Date.now()}`;
254
+ const isImg = file.type.startsWith('image/');
255
+ const url = isImg ? URL.createObjectURL(file) : undefined;
256
+ const dims = await this.readDimensions(url);
257
+ const item: ImgItem = {
258
+ id,
259
+ name: file.name,
260
+ url,
261
+ color: isImg ? undefined : '#cccccc',
262
+ size: file.size,
263
+ ...dims,
264
+ ...meta,
265
+ };
266
+ this.images.set(id, { ...item, tags: [] });
267
+ if (meta?.tags) meta.tags.forEach((t) => this.addImageTag(id, this.ensureTag(t)));
268
+ return { ...item, tags: [...(meta?.tags ?? [])] };
269
+ }
270
+
271
+ async addImagesToAlbum(imageIds: string[], albumId: string): Promise<void> {
272
+ await delay(this.latency);
273
+ if (!this.albumImages.has(albumId)) this.albumImages.set(albumId, new Set());
274
+ const set = this.albumImages.get(albumId)!;
275
+ imageIds.forEach((id) => {
276
+ if (this.images.has(id)) set.add(id); // 幂等去重
277
+ });
278
+ }
279
+
280
+ async removeImagesFromAlbum(imageIds: string[], albumId: string): Promise<void> {
281
+ await delay(this.latency);
282
+ const set = this.albumImages.get(albumId);
283
+ if (set) imageIds.forEach((id) => set.delete(id));
284
+ }
285
+
286
+ async renameImage(id: string, name: string): Promise<ImgItem> {
287
+ await delay(this.latency);
288
+ const img = this.images.get(id);
289
+ if (!img) throw new Error(`Image not found: ${id}`);
290
+ img.name = name;
291
+ return { ...img, tags: [...(img.tags ?? [])] };
292
+ }
293
+
294
+ async deleteImages(ids: string[]): Promise<void> {
295
+ await delay(this.latency);
296
+ ids.forEach((id) => {
297
+ this.images.delete(id);
298
+ this.imageTags.delete(id);
299
+ this.albumImages.forEach((set) => set.delete(id));
300
+ });
301
+ }
302
+
303
+ async getImageInfo(id: string): Promise<ImageInfo> {
304
+ await delay(this.latency);
305
+ const img = this.images.get(id);
306
+ if (!img) throw new Error(`Image not found: ${id}`);
307
+ const tagIds = this.imageTags.get(id) ?? new Set();
308
+ const tags = Array.from(tagIds).map((tid) => this.tags.get(tid) ?? '');
309
+ const albums: string[] = [];
310
+ this.albumImages.forEach((set, albumId) => {
311
+ if (set.has(id)) {
312
+ const a = this.albums.get(albumId);
313
+ if (a) albums.push(a.name);
314
+ }
315
+ });
316
+ return {
317
+ id,
318
+ name: img.name,
319
+ size: img.size,
320
+ width: img.width,
321
+ height: img.height,
322
+ tags,
323
+ albums,
324
+ };
325
+ }
326
+
327
+ // ---------------------------- Tags ----------------------------
328
+ async getTags(query?: string): Promise<TagModel[]> {
329
+ await delay(this.latency);
330
+ const q = query?.trim().toLowerCase();
331
+ return Array.from(this.tags.entries())
332
+ .filter(([, text]) => !q || text.toLowerCase().includes(q))
333
+ .map(([id, text]) => ({ id, text }));
334
+ }
335
+
336
+ async getTagsForImage(imageId: string): Promise<TagModel[]> {
337
+ await delay(this.latency);
338
+ const tagIds = this.imageTags.get(imageId) ?? new Set();
339
+ return Array.from(tagIds).map((tid) => ({ id: tid, text: this.tags.get(tid) ?? '' }));
340
+ }
341
+
342
+ async tagImage(imageIds: string[], tag: string): Promise<void> {
343
+ await delay(this.latency);
344
+ const tid = this.ensureTag(tag);
345
+ imageIds.forEach((id) => {
346
+ if (this.images.has(id)) this.addImageTag(id, tid);
347
+ });
348
+ }
349
+
350
+ async untagImage(imageId: string, tag: string): Promise<void> {
351
+ await delay(this.latency);
352
+ let tid: string | undefined;
353
+ for (const [id, t] of this.tags) if (t === tag) { tid = id; break; }
354
+ if (tid && this.imageTags.has(imageId)) this.imageTags.get(imageId)!.delete(tid);
355
+ }
356
+ }
357
+
358
+ /** 创建内存 mock 服务实例(默认数据源)。seed 通常来自组件初始 albums/images 或后端预置。 */
359
+ export function createMockImageOrganizerService(seed?: MockSeed, latency = 0): ImageOrganizerService {
360
+ return new MockImageOrganizerService(seed, latency);
361
+ }
@@ -0,0 +1,221 @@
1
+ import React, {
2
+ useState,
3
+ useMemo,
4
+ useId,
5
+ forwardRef,
6
+ useImperativeHandle,
7
+ useRef,
8
+ } from 'react';
9
+
10
+ export interface ListViewColumn {
11
+ /** 列标题 */
12
+ header: string;
13
+ /** 数据字段名 */
14
+ dataIndex: string;
15
+ /** 占容器宽度的比例(0~1),未指定时与其他未指定列均分剩余比例 */
16
+ width?: number;
17
+ /** 文本对齐:left/center/right(默认 left) */
18
+ align?: 'left' | 'center' | 'right';
19
+ /** 单元格内容格式化函数(默认直接显示原始值) */
20
+ format?: (value: unknown, row: Record<string, unknown>) => React.ReactNode;
21
+ }
22
+
23
+ export interface ListViewProps {
24
+ /** 容器宽度(px),等同 Ext width 配置,注入到根元素 */
25
+ width?: number;
26
+ /** 容器高度(px),等同 Ext height 配置,注入到根元素 */
27
+ height?: number;
28
+ /** 列定义 */
29
+ columns: ListViewColumn[];
30
+ /** 行数据(静态项来源;动态增删经 ref 接口,不破坏此 prop) */
31
+ data: Record<string, unknown>[];
32
+ /** 是否多选(Ctrl+点击累加),等同 Ext multiSelect */
33
+ multiSelect?: boolean;
34
+ /** 初始选中行索引(可多选传数组) */
35
+ value?: number | number[];
36
+ /** 根容器附加 class(测试用 data-testid 等由外层包裹) */
37
+ className?: string;
38
+ style?: React.CSSProperties;
39
+ }
40
+
41
+ export interface ListViewHandle {
42
+ /** 追加一行,返回该行稳定 id(可用于 remove)。data 为静态项来源不受影响。 */
43
+ add: (row: Record<string, unknown>, id?: string) => string;
44
+ /** 按动态 id 删除一行 */
45
+ remove: (id: string) => void;
46
+ /** 按当前(合并后)行索引删除一行,并同步调整选中索引 */
47
+ removeByIndex: (index: number) => void;
48
+ /** 返回合并后的全部行数据 */
49
+ getItems: () => Record<string, unknown>[];
50
+ /** 返回当前选中行索引数组 */
51
+ getSelected: () => number[];
52
+ }
53
+
54
+ const HEADER_HEIGHT = 22;
55
+
56
+ /**
57
+ * 复刻 Ext.list.ListView(Ext 3.2.1)。
58
+ * DOM 严格对齐 src/widgets/list/ListView.js:
59
+ * x-list-wrap(注入 width/height)
60
+ * > x-list-header > x-list-header-inner
61
+ * > (每列) div[style width %;text-align] > em[unselectable=on]{header}
62
+ * > div.x-clear
63
+ * > x-list-body[height=总高-22] > x-list-body-inner
64
+ * > (每行) dl[选中 x-list-selected / 悬停 x-list-over]
65
+ * > (每列) dt[width %;text-align] > em[unselectable=on]{value}
66
+ * > div.x-clear
67
+ * 仅用 ext-all.css 已有规则(x-list-*),零 CSS 契约破坏。
68
+ *
69
+ * 动态增删沿用「data prop 为静态项来源 + 独立 dynamicData 状态」双轨:
70
+ * managed = [...data种子, ...dynamicData],未开动态时 managed===data 映射,DOM 完全一致。
71
+ */
72
+ export const ListView = forwardRef<ListViewHandle, ListViewProps>(function ListView(
73
+ {
74
+ width = 300,
75
+ height = 200,
76
+ columns,
77
+ data,
78
+ multiSelect = false,
79
+ value,
80
+ className,
81
+ style,
82
+ },
83
+ ref
84
+ ) {
85
+ const baseId = useId().replace(/:/g, '');
86
+ const dynIdRef = useRef(0);
87
+ const [dynamicData, setDynamicData] = useState<
88
+ { id: string; row: Record<string, unknown> }[]
89
+ >([]);
90
+ // 被程序化删除的静态种子行(按原始 data 索引),对齐 Ext store.remove 可删任意记录
91
+ const [removedStatic, setRemovedStatic] = useState<Set<number>>(new Set());
92
+
93
+ // 计算列宽比例(对齐 ListView.initComponent 的 auto 分配算法)
94
+ const resolvedColumns = useMemo(() => {
95
+ const maxWidth = 100;
96
+ const allocated = columns.reduce((s, c) => s + (c.width ? c.width * 100 : 0), 0);
97
+ const without = columns.filter((c) => !c.width).length;
98
+ const remaining = maxWidth - allocated;
99
+ const per = without > 0 ? remaining / without / 100 : 0;
100
+ return columns.map((c) => ({ ...c, _w: (c.width ?? per) * 100 }));
101
+ }, [columns]);
102
+
103
+ // 双轨:静态项种子(剔除已删)+ 独立动态态,绝不被 data prop 变更冲掉
104
+ const managed = useMemo(() => {
105
+ const staticItems = data
106
+ .map((row, i) => ({ key: `seed-${i}`, row, origin: 'seed' as const, dataIdx: i }))
107
+ .filter((it) => !removedStatic.has(it.dataIdx));
108
+ const dynItems = dynamicData.map((d) => ({
109
+ key: `dyn-${d.id}`,
110
+ row: d.row,
111
+ origin: 'dyn' as const,
112
+ }));
113
+ return [...staticItems, ...dynItems];
114
+ }, [data, dynamicData, removedStatic]);
115
+
116
+ const [selected, setSelected] = useState<Set<number>>(() => {
117
+ const s = new Set<number>();
118
+ if (Array.isArray(value)) value.forEach((i) => s.add(i));
119
+ else if (typeof value === 'number') s.add(value);
120
+ return s;
121
+ });
122
+ const [hovered, setHovered] = useState<number | null>(null);
123
+
124
+ useImperativeHandle(
125
+ ref,
126
+ () => ({
127
+ add: (row: Record<string, unknown>, id?: string) => {
128
+ const newId = id ?? `d${++dynIdRef.current}`;
129
+ setDynamicData((prev) => [...prev, { id: newId, row }]);
130
+ return newId;
131
+ },
132
+ remove: (id: string) =>
133
+ setDynamicData((prev) => prev.filter((d) => d.id !== id)),
134
+ removeByIndex: (index: number) => {
135
+ const item = managed[index];
136
+ if (!item) return;
137
+ if (item.origin === 'dyn') {
138
+ setDynamicData((prev) => prev.filter((d) => `dyn-${d.id}` !== item.key));
139
+ } else {
140
+ setRemovedStatic((prev) => new Set(prev).add(item.dataIdx));
141
+ }
142
+ // 同步调整选中索引(删除点之后的索引前移)
143
+ setSelected((prev) => {
144
+ const next = new Set<number>();
145
+ prev.forEach((s) => {
146
+ if (s === index) return;
147
+ next.add(s > index ? s - 1 : s);
148
+ });
149
+ return next;
150
+ });
151
+ },
152
+ getItems: () => managed.map((m) => m.row),
153
+ getSelected: () => Array.from(selected).sort((a, b) => a - b),
154
+ }),
155
+ [managed, selected]
156
+ );
157
+
158
+ const handleRowClick = (idx: number, e: React.MouseEvent) => {
159
+ if (multiSelect && (e.ctrlKey || e.metaKey)) {
160
+ setSelected((prev) => {
161
+ const next = new Set(prev);
162
+ if (next.has(idx)) next.delete(idx);
163
+ else next.add(idx);
164
+ return next;
165
+ });
166
+ } else {
167
+ setSelected(new Set([idx]));
168
+ }
169
+ };
170
+
171
+ return (
172
+ <div
173
+ className={['x-list-wrap', className].filter(Boolean).join(' ')}
174
+ style={{ width, height, ...style }}
175
+ >
176
+ <div className="x-list-header">
177
+ <div className="x-list-header-inner">
178
+ {resolvedColumns.map((c, i) => (
179
+ <div key={i} style={{ width: `${c._w}%`, textAlign: c.align ?? 'left' }}>
180
+ <em unselectable="on" id={`${baseId}-xlhd-${i + 1}`}>
181
+ {c.header}
182
+ </em>
183
+ </div>
184
+ ))}
185
+ <div className="x-clear"></div>
186
+ </div>
187
+ </div>
188
+ <div className="x-list-body" style={{ height: height - HEADER_HEIGHT }}>
189
+ <div className="x-list-body-inner">
190
+ {managed.map((item, ri) => (
191
+ <dl
192
+ key={item.key}
193
+ className={[
194
+ selected.has(ri) ? 'x-list-selected' : '',
195
+ hovered === ri ? 'x-list-over' : '',
196
+ ]
197
+ .filter(Boolean)
198
+ .join(' ')}
199
+ onMouseEnter={() => setHovered(ri)}
200
+ onMouseLeave={() => setHovered((h) => (h === ri ? null : h))}
201
+ onClick={(e) => handleRowClick(ri, e)}
202
+ >
203
+ {resolvedColumns.map((c, ci) => {
204
+ const raw = item.row[c.dataIndex];
205
+ const content = c.format
206
+ ? c.format(raw, item.row)
207
+ : (raw as React.ReactNode);
208
+ return (
209
+ <dt key={ci} style={{ width: `${c._w}%`, textAlign: c.align ?? 'left' }}>
210
+ <em unselectable="on">{content}</em>
211
+ </dt>
212
+ );
213
+ })}
214
+ <div className="x-clear"></div>
215
+ </dl>
216
+ ))}
217
+ </div>
218
+ </div>
219
+ </div>
220
+ );
221
+ });
@@ -0,0 +1,74 @@
1
+ import React, { type CSSProperties, type ReactNode } from 'react';
2
+
3
+ export interface LoadMaskProps {
4
+ /** 是否显示遮罩(默认 true) */
5
+ loading?: boolean;
6
+ /** 遮罩消息文案(默认 'Loading...') */
7
+ msg?: string;
8
+ /** 消息框附加 class(默认 'x-mask-loading',带 loading.gif) */
9
+ msgCls?: string;
10
+ /** 容器宽度(默认 300) */
11
+ width?: number | string;
12
+ /** 容器高度(默认 160) */
13
+ height?: number | string;
14
+ /** 测试锚点(落在根元素,用于 fidelity 对比) */
15
+ testId?: string;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ children?: ReactNode;
19
+ }
20
+
21
+ /**
22
+ * 复刻 Ext.LoadMask / Ext.Element.mask(msg, msgCls)。
23
+ *
24
+ * DOM 对齐 Element.fx-more.js 的 mask():
25
+ * <div class="x-masked" style="position:relative;overflow:hidden"> (被遮罩容器)
26
+ * {children}
27
+ * <div class="ext-el-mask" /> (半透明遮罩层,铺满容器)
28
+ * <div class="ext-el-mask-msg {msgCls}"> (居中消息框)
29
+ * <div>{msg}</div> (消息文本)
30
+ * </div>
31
+ * </div>
32
+ *
33
+ * 视觉完全由 ext-all.css 既有规则驱动(零 CSS 契约破坏):
34
+ * .ext-el-mask -> 背景 #ccc,opacity .5,铺满
35
+ * .ext-el-mask-msg -> 蓝色边框 + tb-blue.gif 横纹背景
36
+ * .ext-el-mask-msg div -> #eee 底 + 边框 + 11px tahoma
37
+ * .x-mask-loading div -> loading.gif 旋转图标 + #fbfbfb
38
+ */
39
+ export const LoadMask: React.FC<LoadMaskProps> = ({
40
+ loading = true,
41
+ msg = 'Loading...',
42
+ msgCls = 'x-mask-loading',
43
+ width = 300,
44
+ height = 160,
45
+ testId,
46
+ className,
47
+ style,
48
+ children,
49
+ }) => {
50
+ return (
51
+ <div
52
+ data-testid={testId}
53
+ className={['x-masked', className].filter(Boolean).join(' ')}
54
+ style={{
55
+ position: 'relative',
56
+ width,
57
+ height,
58
+ overflow: 'hidden',
59
+ ...style,
60
+ }}
61
+ >
62
+ {children}
63
+ {loading && <div className="ext-el-mask" />}
64
+ {loading && msg != null && (
65
+ <div
66
+ className={['ext-el-mask-msg', msgCls].filter(Boolean).join(' ')}
67
+ style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }}
68
+ >
69
+ <div>{msg}</div>
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ };