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,984 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
import { TreePanel, type TreeNodeData, type TreePanelHandle } from './Tree';
|
|
12
|
+
import { TabPanel, type TabPanelHandle } from './TabPanel';
|
|
13
|
+
import { Button } from './Button';
|
|
14
|
+
import { BorderLayout } from './BorderLayout';
|
|
15
|
+
import { Toolbar } from './Toolbar';
|
|
16
|
+
import { Slider } from './Slider';
|
|
17
|
+
import { Menu, type MenuItem } from './Menu';
|
|
18
|
+
import { ComboBox } from './Form';
|
|
19
|
+
import {
|
|
20
|
+
type ImgItem,
|
|
21
|
+
type AlbumModel,
|
|
22
|
+
type TagModel,
|
|
23
|
+
type ImageOrganizerService,
|
|
24
|
+
createMockImageOrganizerService,
|
|
25
|
+
} from './ImageOrganizerService';
|
|
26
|
+
|
|
27
|
+
// 类型从服务层统一导出,避免重复定义(单一来源)
|
|
28
|
+
export type { ImgItem, AlbumModel, TagModel } from './ImageOrganizerService';
|
|
29
|
+
|
|
30
|
+
export interface ImageOrganizerProps {
|
|
31
|
+
/** 初始相册(分组关系),默认空数组。仅当未显式传入 `service` 时用于播种内存 mock */
|
|
32
|
+
albums?: AlbumModel[];
|
|
33
|
+
/** 初始图片(All Photos 网格),默认空数组。同上,仅播种用 */
|
|
34
|
+
images?: ImgItem[];
|
|
35
|
+
/** 服务端实现:组件所有数据操作均经由此接口(内存 mock 或可替换为真实 HTTP 后端)。
|
|
36
|
+
* 不传则内部用 `albums`/`images` 播种一个 MockImageOrganizerService。 */
|
|
37
|
+
service?: ImageOrganizerService;
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
/** 新建相册按钮文案,默认 'Add Album' */
|
|
41
|
+
newAlbumText?: string;
|
|
42
|
+
/** 新建相册默认名前缀,默认 'Album ' */
|
|
43
|
+
albumNamePrefix?: string;
|
|
44
|
+
/** 上传按钮文案,默认 'Upload'(等价原始 SWFUpload 选择文件桩) */
|
|
45
|
+
uploadText?: string;
|
|
46
|
+
/** 相册分组变化回调(增/删/改名/拖入图片后) */
|
|
47
|
+
onAlbumsChange?: (albums: AlbumModel[]) => void;
|
|
48
|
+
/** 图片集合变化回调(上传/删除/改名后) */
|
|
49
|
+
onImagesChange?: (images: ImgItem[]) => void;
|
|
50
|
+
/** 图片被拖入某相册时回调(等价 ImageDragZone → TreeDropZone 插入叶子的代理回调);多选批量各回调一次 */
|
|
51
|
+
onImageDropped?: (image: ImgItem, albumId: string) => void;
|
|
52
|
+
/** 图片上传后回调(等价 SWFUpload 上传完成写入 store) */
|
|
53
|
+
onImageUploaded?: (image: ImgItem) => void;
|
|
54
|
+
/** 图片改名后回调(等价 LabelEditor 提交 filename) */
|
|
55
|
+
onImageRenamed?: (image: ImgItem, oldName: string) => void;
|
|
56
|
+
className?: string;
|
|
57
|
+
style?: React.CSSProperties;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ImageOrganizerHandle {
|
|
61
|
+
/** 新增相册(自动命名并进入内联改名态),返回新节点 id */
|
|
62
|
+
addAlbum: (text?: string) => string;
|
|
63
|
+
/** 新增一张图片到 All Photos */
|
|
64
|
+
addImage: (image: ImgItem) => void;
|
|
65
|
+
/** 以本地文件列表触发上传桩(等价 swfu.selectFiles + 上传完成) */
|
|
66
|
+
uploadFiles: (files: FileList | File[]) => void;
|
|
67
|
+
/** 读取当前相册分组(实时快照) */
|
|
68
|
+
getAlbums: () => AlbumModel[];
|
|
69
|
+
/** 读取当前图片列表(实时快照) */
|
|
70
|
+
getImages: () => ImgItem[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const IO_IMAGE_LIST_MIME = 'application/x-organizer-image-list';
|
|
74
|
+
|
|
75
|
+
const FS = (n: number | undefined): string => {
|
|
76
|
+
if (!n && n !== 0) return '—';
|
|
77
|
+
if (n < 1024) return `${n} B`;
|
|
78
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
79
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 自包含 SVG 缩略图 data-uri(等价原始 DataView 的 <img> 资源) */
|
|
83
|
+
const makeThumb = (color: string, label: string, w = 80, h = 60): string => {
|
|
84
|
+
const svg =
|
|
85
|
+
`<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}'>` +
|
|
86
|
+
`<rect width='${w}' height='${h}' fill='${color}'/>` +
|
|
87
|
+
`<text x='${w / 2}' y='${h / 2 + 4}' font-size='11' fill='#333' text-anchor='middle' ` +
|
|
88
|
+
`font-family='sans-serif'>${label.replace(/[<&]/g, '')}</text></svg>`;
|
|
89
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const thumbUrl = (img: ImgItem): string =>
|
|
93
|
+
img.url ?? makeThumb(img.color ?? '#dddddd', img.name);
|
|
94
|
+
|
|
95
|
+
const leafId = (albumId: string, imgId: string) => `${albumId}::${imgId}`;
|
|
96
|
+
const parseLeaf = (id: string): string | null => {
|
|
97
|
+
const i = id.indexOf('::');
|
|
98
|
+
return i >= 0 ? id.slice(i + 2) : null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** AlbumModel[] → 相册树节点(相册为 folder 节点,子为图片叶子,leaf id 编码 albumId::imgId) */
|
|
102
|
+
const albumsToNodes = (albums: AlbumModel[], imgMap: Map<string, ImgItem>): TreeNodeData[] =>
|
|
103
|
+
albums.map((a) => ({
|
|
104
|
+
id: a.id,
|
|
105
|
+
text: a.name,
|
|
106
|
+
cls: 'album-node',
|
|
107
|
+
expandable: true,
|
|
108
|
+
children: a.imageIds.map((imgId) => {
|
|
109
|
+
const img = imgMap.get(imgId);
|
|
110
|
+
return {
|
|
111
|
+
id: leafId(a.id, imgId),
|
|
112
|
+
text: img?.name ?? imgId,
|
|
113
|
+
icon: img ? thumbUrl(img) : undefined,
|
|
114
|
+
cls: 'image-node',
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
/** 相册树节点 → AlbumModel[](从图片叶子 id 还原 imageIds) */
|
|
120
|
+
const nodesToAlbums = (nodes: TreeNodeData[]): AlbumModel[] =>
|
|
121
|
+
nodes
|
|
122
|
+
.filter((n) => n.cls === 'album-node' || n.children?.length)
|
|
123
|
+
.map((n) => ({
|
|
124
|
+
id: n.id,
|
|
125
|
+
name: n.text,
|
|
126
|
+
imageIds: (n.children ?? [])
|
|
127
|
+
.filter((c) => c.cls === 'image-node')
|
|
128
|
+
.map((c) => parseLeaf(c.id))
|
|
129
|
+
.filter((x): x is string => Boolean(x)),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// ============================ 缩略图墙(ImageThumbPanel) ============================
|
|
133
|
+
interface ThumbPanelProps {
|
|
134
|
+
images: ImgItem[];
|
|
135
|
+
/** 可选:标签下拉候选项(来自 service.getTags);不传则按当前图片推导 */
|
|
136
|
+
tagOptions?: string[];
|
|
137
|
+
onOpenImage: (img: ImgItem) => void;
|
|
138
|
+
onRenameImage: (img: ImgItem, oldName: string) => void;
|
|
139
|
+
onRemoveImage: (img: ImgItem) => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ThumbPanel: React.FC<ThumbPanelProps> = ({ images, tagOptions, onOpenImage, onRenameImage, onRemoveImage }) => {
|
|
143
|
+
const [selectedImgs, setSelectedImgs] = useState<Set<string>>(new Set());
|
|
144
|
+
const [selBox, setSelBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
|
145
|
+
const [thumbH, setThumbH] = useState(80);
|
|
146
|
+
const [tagFilter, setTagFilter] = useState('all');
|
|
147
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
148
|
+
const [editingVal, setEditingVal] = useState('');
|
|
149
|
+
const [ctx, setCtx] = useState<{ x: number; y: number; img: ImgItem | null } | null>(null);
|
|
150
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
151
|
+
|
|
152
|
+
const allTags = useMemo(() => {
|
|
153
|
+
const s = new Set<string>();
|
|
154
|
+
images.forEach((i) => (i.tags ?? []).forEach((t) => s.add(t)));
|
|
155
|
+
return Array.from(s);
|
|
156
|
+
}, [images]);
|
|
157
|
+
|
|
158
|
+
const comboOptions = tagOptions && tagOptions.length ? tagOptions : allTags;
|
|
159
|
+
|
|
160
|
+
const shown = useMemo(() => {
|
|
161
|
+
if (tagFilter === 'all') return images;
|
|
162
|
+
return images.filter((i) => (i.tags ?? []).includes(tagFilter));
|
|
163
|
+
}, [images, tagFilter]);
|
|
164
|
+
|
|
165
|
+
const thumbW = Math.round(thumbH * 4 / 3);
|
|
166
|
+
|
|
167
|
+
// 橡皮筋框选(等价 DataView + DragSelector)
|
|
168
|
+
const onMouseDown = (e: React.MouseEvent) => {
|
|
169
|
+
if (e.button !== 0) return;
|
|
170
|
+
if ((e.target as HTMLElement).closest('.io-thumb-wrap')) return;
|
|
171
|
+
const cont = scrollRef.current;
|
|
172
|
+
if (!cont) return;
|
|
173
|
+
const rect = cont.getBoundingClientRect();
|
|
174
|
+
const sx = e.clientX - rect.left + cont.scrollLeft;
|
|
175
|
+
const sy = e.clientY - rect.top + cont.scrollTop;
|
|
176
|
+
const additive = e.ctrlKey || e.metaKey;
|
|
177
|
+
const base = additive ? new Set(selectedImgs) : new Set<string>();
|
|
178
|
+
setSelectedImgs(base);
|
|
179
|
+
setSelBox({ x1: sx, y1: sy, x2: sx, y2: sy });
|
|
180
|
+
const onMove = (ev: MouseEvent) => {
|
|
181
|
+
const r = cont.getBoundingClientRect();
|
|
182
|
+
const cx = ev.clientX - r.left + cont.scrollLeft;
|
|
183
|
+
const cy = ev.clientY - r.top + cont.scrollTop;
|
|
184
|
+
setSelBox({ x1: sx, y1: sy, x2: cx, y2: cy });
|
|
185
|
+
const box = {
|
|
186
|
+
left: Math.min(sx, cx),
|
|
187
|
+
top: Math.min(sy, cy),
|
|
188
|
+
right: Math.max(sx, cx),
|
|
189
|
+
bottom: Math.max(sy, cy),
|
|
190
|
+
};
|
|
191
|
+
const sel = new Set(base);
|
|
192
|
+
cont.querySelectorAll<HTMLElement>('.io-thumb-wrap').forEach((w) => {
|
|
193
|
+
const wr = w.getBoundingClientRect();
|
|
194
|
+
const wl = wr.left - r.left + cont.scrollLeft;
|
|
195
|
+
const wt = wr.top - r.top + cont.scrollTop;
|
|
196
|
+
const wright = wl + wr.width;
|
|
197
|
+
const wb = wt + wr.height;
|
|
198
|
+
if (wl < box.right && wright > box.left && wt < box.bottom && wb > box.top) {
|
|
199
|
+
const id = w.getAttribute('data-img-id');
|
|
200
|
+
if (id) sel.add(id);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
setSelectedImgs(sel);
|
|
204
|
+
};
|
|
205
|
+
const onUp = () => {
|
|
206
|
+
window.removeEventListener('mousemove', onMove);
|
|
207
|
+
window.removeEventListener('mouseup', onUp);
|
|
208
|
+
setSelBox(null);
|
|
209
|
+
};
|
|
210
|
+
window.addEventListener('mousemove', onMove);
|
|
211
|
+
window.addEventListener('mouseup', onUp);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const buildDragImage = (count: number): HTMLElement => {
|
|
215
|
+
// 复用原版 .x-dd-drag-ghost(xtheme 已定义背景/边框/文字色),不写死颜色
|
|
216
|
+
const el = document.createElement('div');
|
|
217
|
+
el.className = 'x-dd-drag-ghost';
|
|
218
|
+
el.style.cssText = 'position:absolute;top:-1000px;left:-1000px;';
|
|
219
|
+
el.textContent = `${count} image${count > 1 ? 's' : ''}`;
|
|
220
|
+
document.body.appendChild(el);
|
|
221
|
+
return el;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const ctxItems: MenuItem[] = ctx
|
|
225
|
+
? [
|
|
226
|
+
{ text: 'View in Tab', handler: () => { if (ctx.img) onOpenImage(ctx.img); setCtx(null); } },
|
|
227
|
+
{ text: 'Remove', handler: () => { if (ctx.img) onRemoveImage(ctx.img); setCtx(null); } },
|
|
228
|
+
{ separator: true, text: '' },
|
|
229
|
+
{ text: 'Close', handler: () => setCtx(null) },
|
|
230
|
+
]
|
|
231
|
+
: [];
|
|
232
|
+
|
|
233
|
+
if (shown.length === 0) {
|
|
234
|
+
return (
|
|
235
|
+
<div style={{ flex: 1, overflow: 'auto', position: 'relative' }}>
|
|
236
|
+
<div className="images-view" style={{ padding: 10, fontSize: 12 }}>
|
|
237
|
+
No images to display
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className="images-view" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
245
|
+
{/* bbar:Tags 过滤 + 尺寸滑块(等价原始 ImageThumbPanel 的 bbar) */}
|
|
246
|
+
<div className="x-toolbar" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 6px' }}>
|
|
247
|
+
<span style={{ fontSize: 11 }}>Tags:</span>
|
|
248
|
+
<ComboBox
|
|
249
|
+
data={[{ value: 'all', text: 'All Tags' }, ...comboOptions.map((t) => ({ value: t, text: t }))]}
|
|
250
|
+
value={tagFilter}
|
|
251
|
+
width={130}
|
|
252
|
+
onChange={(v) => setTagFilter(v)}
|
|
253
|
+
/>
|
|
254
|
+
<div style={{ flex: 1 }} />
|
|
255
|
+
<span style={{ fontSize: 11 }}>Size:</span>
|
|
256
|
+
<Slider
|
|
257
|
+
minValue={20}
|
|
258
|
+
maxValue={120}
|
|
259
|
+
value={thumbH}
|
|
260
|
+
width={120}
|
|
261
|
+
onChange={(v) => setThumbH(v)}
|
|
262
|
+
onChangeComplete={(v) => setThumbH(v)}
|
|
263
|
+
getTipText={(v) => `Height: <b>${v}</b>px`}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<div
|
|
267
|
+
ref={scrollRef}
|
|
268
|
+
data-org="img-scroll"
|
|
269
|
+
style={{ flex: 1, overflow: 'auto', padding: 8, position: 'relative' }}
|
|
270
|
+
onMouseDown={onMouseDown}
|
|
271
|
+
onContextMenu={(e) => {
|
|
272
|
+
const wrap = (e.target as HTMLElement).closest('.io-thumb-wrap');
|
|
273
|
+
const id = wrap?.getAttribute('data-img-id');
|
|
274
|
+
const img = id ? shown.find((i) => i.id === id) ?? null : null;
|
|
275
|
+
setCtx({ x: e.clientX, y: e.clientY, img });
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<div style={{ overflow: 'hidden', position: 'relative' }}>
|
|
279
|
+
{shown.map((img) => {
|
|
280
|
+
const sel = selectedImgs.has(img.id);
|
|
281
|
+
const editing = editingId === img.id;
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
key={img.id}
|
|
285
|
+
className={['io-thumb-wrap', sel ? 'x-list-selected' : ''].filter(Boolean).join(' ')}
|
|
286
|
+
data-img-id={img.id}
|
|
287
|
+
draggable
|
|
288
|
+
onDragStart={(e) => {
|
|
289
|
+
let dragged = selectedImgs;
|
|
290
|
+
if (!dragged.has(img.id)) {
|
|
291
|
+
dragged = new Set([img.id]);
|
|
292
|
+
setSelectedImgs(dragged);
|
|
293
|
+
}
|
|
294
|
+
const ids = Array.from(dragged);
|
|
295
|
+
e.dataTransfer.setData(IO_IMAGE_LIST_MIME, JSON.stringify(ids));
|
|
296
|
+
e.dataTransfer.setData('text/plain', img.id);
|
|
297
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
298
|
+
const ghost = buildDragImage(ids.length);
|
|
299
|
+
try { e.dataTransfer.setDragImage(ghost, 0, 0); } catch { /* ignore */ }
|
|
300
|
+
setTimeout(() => ghost.remove(), 0);
|
|
301
|
+
}}
|
|
302
|
+
onClick={(e) => {
|
|
303
|
+
if (e.ctrlKey || e.metaKey) {
|
|
304
|
+
setSelectedImgs((prev) => {
|
|
305
|
+
const n = new Set(prev);
|
|
306
|
+
if (n.has(img.id)) n.delete(img.id); else n.add(img.id);
|
|
307
|
+
return n;
|
|
308
|
+
});
|
|
309
|
+
} else setSelectedImgs(new Set([img.id]));
|
|
310
|
+
}}
|
|
311
|
+
onDoubleClick={() => onOpenImage(img)}
|
|
312
|
+
style={{
|
|
313
|
+
float: 'left',
|
|
314
|
+
margin: 4,
|
|
315
|
+
padding: 5,
|
|
316
|
+
width: thumbW + 12,
|
|
317
|
+
textAlign: 'center',
|
|
318
|
+
cursor: 'grab',
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<div className="thumb" style={{ width: thumbW, height: thumbH, margin: '0 auto 3px' }}>
|
|
322
|
+
<img
|
|
323
|
+
className="thumb-img"
|
|
324
|
+
src={thumbUrl(img)}
|
|
325
|
+
alt={img.name}
|
|
326
|
+
draggable={false}
|
|
327
|
+
style={{
|
|
328
|
+
width: '100%',
|
|
329
|
+
height: '100%',
|
|
330
|
+
boxSizing: 'border-box',
|
|
331
|
+
display: 'block',
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
{editing ? (
|
|
336
|
+
<input
|
|
337
|
+
autoFocus
|
|
338
|
+
value={editingVal}
|
|
339
|
+
onChange={(e) => setEditingVal(e.target.value)}
|
|
340
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
341
|
+
onBlur={() => {
|
|
342
|
+
const old = img.name;
|
|
343
|
+
setEditingId(null);
|
|
344
|
+
if (editingVal && editingVal.trim() && editingVal.trim() !== old) {
|
|
345
|
+
onRenameImage({ ...img, name: editingVal.trim() }, old);
|
|
346
|
+
}
|
|
347
|
+
}}
|
|
348
|
+
onKeyDown={(e) => {
|
|
349
|
+
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
|
350
|
+
else if (e.key === 'Escape') setEditingId(null);
|
|
351
|
+
}}
|
|
352
|
+
style={{ width: '100%', fontSize: 11, boxSizing: 'border-box' }}
|
|
353
|
+
/>
|
|
354
|
+
) : (
|
|
355
|
+
<span
|
|
356
|
+
className="x-editable"
|
|
357
|
+
onDoubleClick={(e) => {
|
|
358
|
+
e.stopPropagation();
|
|
359
|
+
setEditingId(img.id);
|
|
360
|
+
setEditingVal(img.name);
|
|
361
|
+
}}
|
|
362
|
+
style={{
|
|
363
|
+
display: 'block',
|
|
364
|
+
fontSize: 11,
|
|
365
|
+
overflow: 'hidden',
|
|
366
|
+
textOverflow: 'ellipsis',
|
|
367
|
+
whiteSpace: 'nowrap',
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
{img.name}
|
|
371
|
+
</span>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
})}
|
|
376
|
+
</div>
|
|
377
|
+
{selBox && (
|
|
378
|
+
<div
|
|
379
|
+
className="x-view-selector"
|
|
380
|
+
style={{
|
|
381
|
+
position: 'absolute',
|
|
382
|
+
zIndex: 5,
|
|
383
|
+
pointerEvents: 'none',
|
|
384
|
+
left: Math.min(selBox.x1, selBox.x2),
|
|
385
|
+
top: Math.min(selBox.y1, selBox.y2),
|
|
386
|
+
width: Math.abs(selBox.x2 - selBox.x1),
|
|
387
|
+
height: Math.abs(selBox.y2 - selBox.y1),
|
|
388
|
+
}}
|
|
389
|
+
/>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
{ctx && createPortal(
|
|
393
|
+
<div style={{ position: 'absolute', left: ctx.x, top: ctx.y, zIndex: 15000 }}>
|
|
394
|
+
<Menu items={ctxItems} />
|
|
395
|
+
</div>,
|
|
396
|
+
document.body,
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// ============================ 相册画廊(AlbumsPanel) ============================
|
|
403
|
+
const AlbumsView: React.FC<{
|
|
404
|
+
stateRef: React.MutableRefObject<{ images: ImgItem[]; albums: AlbumModel[] }>;
|
|
405
|
+
onOpenAlbum: (albumId: string) => void;
|
|
406
|
+
}> = ({ stateRef, onOpenAlbum }) => {
|
|
407
|
+
const { albums, images } = stateRef.current;
|
|
408
|
+
const imgMap = useMemo(() => new Map(images.map((i) => [i.id, i])), [images]);
|
|
409
|
+
const cards = albums.map((a) => {
|
|
410
|
+
const cover = (a.coverId && imgMap.get(a.coverId)) || imgMap.get(a.imageIds[0] ?? '') || null;
|
|
411
|
+
return (
|
|
412
|
+
<div
|
|
413
|
+
key={a.id}
|
|
414
|
+
className="album-wrap x-panel-body"
|
|
415
|
+
onClick={() => onOpenAlbum(a.id)}
|
|
416
|
+
style={{
|
|
417
|
+
borderTopWidth: 1,
|
|
418
|
+
borderTopStyle: 'solid',
|
|
419
|
+
margin: '0 8px 12px',
|
|
420
|
+
cursor: 'pointer',
|
|
421
|
+
overflow: 'hidden',
|
|
422
|
+
}}
|
|
423
|
+
>
|
|
424
|
+
<div style={{ height: 110, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
425
|
+
{cover ? (
|
|
426
|
+
<img src={thumbUrl(cover)} alt={a.name} style={{ maxWidth: '100%', maxHeight: '100%', display: 'block' }} />
|
|
427
|
+
) : (
|
|
428
|
+
<span style={{ fontSize: 12 }}>No cover</span>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
<div style={{ padding: '6px 8px' }}>
|
|
432
|
+
<h3 style={{ margin: 0, fontSize: 13 }}>{a.name}</h3>
|
|
433
|
+
<div style={{ fontSize: 11 }}>
|
|
434
|
+
{a.imageIds.length} photo{a.imageIds.length === 1 ? '' : 's'}
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
if (cards.length === 0) {
|
|
441
|
+
return <div style={{ padding: 12, fontSize: 12 }}>No albums yet — click “Add Album”.</div>;
|
|
442
|
+
}
|
|
443
|
+
return (
|
|
444
|
+
<div style={{ flex: 1, overflow: 'auto', padding: '8px 0' }}>
|
|
445
|
+
<div style={{ display: 'flex', flexWrap: 'wrap' }}>{cards}</div>
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// ============================ 单图标签页(ImagePanel) ============================
|
|
451
|
+
const ImageView: React.FC<{
|
|
452
|
+
imgId: string;
|
|
453
|
+
stateRef: React.MutableRefObject<{ images: ImgItem[]; albums: AlbumModel[] }>;
|
|
454
|
+
allTags: TagModel[];
|
|
455
|
+
onAddTag: (imgId: string, tag: string) => void;
|
|
456
|
+
onRemoveTag: (imgId: string, tag: string) => void;
|
|
457
|
+
}> = ({ imgId, stateRef, allTags, onAddTag, onRemoveTag }) => {
|
|
458
|
+
const img = stateRef.current.images.find((i) => i.id === imgId);
|
|
459
|
+
const albums = stateRef.current.albums.filter((a) => a.imageIds.includes(imgId));
|
|
460
|
+
const [tagInput, setTagInput] = useState('');
|
|
461
|
+
if (!img) return <div style={{ padding: 12 }}>Image not found.</div>;
|
|
462
|
+
const tags = img.tags ?? [];
|
|
463
|
+
return (
|
|
464
|
+
<div style={{ display: 'flex', height: '100%', minHeight: 0 }}>
|
|
465
|
+
<div style={{ flex: 1, overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 }}>
|
|
466
|
+
<img
|
|
467
|
+
src={thumbUrl(img)}
|
|
468
|
+
alt={img.name}
|
|
469
|
+
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
<div className="x-panel-body" style={{ width: 220, borderLeftWidth: 1, borderLeftStyle: 'solid', padding: 10, overflow: 'auto' }}>
|
|
473
|
+
<h3 className="image-prop-header" style={{ margin: '0 0 4px', fontSize: 12 }}>File Info</h3>
|
|
474
|
+
<div className="image-prop" style={{ fontSize: 11 }}>Filename: {img.name}</div>
|
|
475
|
+
<div className="image-prop" style={{ fontSize: 11 }}>Size: {FS(img.size)}</div>
|
|
476
|
+
<div className="image-prop" style={{ fontSize: 11 }}>
|
|
477
|
+
Dimensions: {img.width && img.height ? `${img.width} × ${img.height}` : '—'}
|
|
478
|
+
</div>
|
|
479
|
+
<h3 className="image-prop-header" style={{ margin: '12px 0 4px', fontSize: 12 }}>Tags</h3>
|
|
480
|
+
<div style={{ marginBottom: 6 }}>
|
|
481
|
+
{tags.length ? (
|
|
482
|
+
tags.map((t) => (
|
|
483
|
+
<span
|
|
484
|
+
key={t}
|
|
485
|
+
style={{
|
|
486
|
+
display: 'inline-flex',
|
|
487
|
+
alignItems: 'center',
|
|
488
|
+
gap: 4,
|
|
489
|
+
fontSize: 11,
|
|
490
|
+
padding: '1px 4px',
|
|
491
|
+
margin: '0 4px 4px 0',
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
{t}
|
|
495
|
+
<span
|
|
496
|
+
role="button"
|
|
497
|
+
title="Remove tag"
|
|
498
|
+
onClick={() => onRemoveTag(img.id, t)}
|
|
499
|
+
style={{ cursor: 'pointer', fontWeight: 'bold' }}
|
|
500
|
+
>×</span>
|
|
501
|
+
</span>
|
|
502
|
+
))
|
|
503
|
+
) : (
|
|
504
|
+
<span className="image-prop" style={{ fontSize: 11 }}>—</span>
|
|
505
|
+
)}
|
|
506
|
+
</div>
|
|
507
|
+
<div style={{ display: 'flex', gap: 4 }}>
|
|
508
|
+
<input
|
|
509
|
+
value={tagInput}
|
|
510
|
+
list="io-tag-list"
|
|
511
|
+
placeholder="add tag"
|
|
512
|
+
onChange={(e) => setTagInput(e.target.value)}
|
|
513
|
+
onKeyDown={(e) => {
|
|
514
|
+
if (e.key === 'Enter' && tagInput.trim()) { onAddTag(img.id, tagInput.trim()); setTagInput(''); }
|
|
515
|
+
}}
|
|
516
|
+
style={{ width: 90, fontSize: 11, boxSizing: 'border-box' }}
|
|
517
|
+
/>
|
|
518
|
+
<datalist id="io-tag-list">
|
|
519
|
+
{allTags.map((t) => <option key={t.id} value={t.text} />)}
|
|
520
|
+
</datalist>
|
|
521
|
+
<Button
|
|
522
|
+
text="Add"
|
|
523
|
+
onClick={() => { if (tagInput.trim()) { onAddTag(img.id, tagInput.trim()); setTagInput(''); } }}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
<h3 className="image-prop-header" style={{ margin: '12px 0 4px', fontSize: 12 }}>Album</h3>
|
|
527
|
+
{albums.length ? (
|
|
528
|
+
albums.map((a) => (
|
|
529
|
+
<div key={a.id} className="image-prop" style={{ fontSize: 11 }}>{a.name}</div>
|
|
530
|
+
))
|
|
531
|
+
) : (
|
|
532
|
+
<div className="image-prop" style={{ fontSize: 11 }}>—</div>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// ============================ 主容器 ============================
|
|
540
|
+
export const ImageOrganizer = forwardRef<ImageOrganizerHandle, ImageOrganizerProps>(function ImageOrganizer(
|
|
541
|
+
{
|
|
542
|
+
albums: initAlbums = [],
|
|
543
|
+
images: initImages = [],
|
|
544
|
+
service: serviceProp,
|
|
545
|
+
width = 760,
|
|
546
|
+
height = 480,
|
|
547
|
+
newAlbumText = 'Add Album',
|
|
548
|
+
albumNamePrefix = 'Album ',
|
|
549
|
+
uploadText = 'Upload',
|
|
550
|
+
onAlbumsChange,
|
|
551
|
+
onImagesChange,
|
|
552
|
+
onImageDropped,
|
|
553
|
+
onImageUploaded,
|
|
554
|
+
onImageRenamed,
|
|
555
|
+
className,
|
|
556
|
+
style,
|
|
557
|
+
},
|
|
558
|
+
ref,
|
|
559
|
+
) {
|
|
560
|
+
// 服务端实例:组件唯一数据出入口。未传则用初始数据播种内存 mock。
|
|
561
|
+
const serviceRef = useRef<ImageOrganizerService>(
|
|
562
|
+
serviceProp ?? createMockImageOrganizerService({ albums: initAlbums, images: initImages }),
|
|
563
|
+
);
|
|
564
|
+
const svc = serviceRef.current;
|
|
565
|
+
|
|
566
|
+
const treeRef = useRef<TreePanelHandle>(null);
|
|
567
|
+
const tabRef = useRef<TabPanelHandle>(null);
|
|
568
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
569
|
+
const albumCounter = useRef(0);
|
|
570
|
+
const [images, setImages] = useState<ImgItem[]>([]);
|
|
571
|
+
const [albums, setAlbums] = useState<AlbumModel[]>([]);
|
|
572
|
+
const [tags, setTags] = useState<TagModel[]>([]);
|
|
573
|
+
const [loading, setLoading] = useState(true);
|
|
574
|
+
const stateRef = useRef({ images, albums });
|
|
575
|
+
stateRef.current = { images, albums };
|
|
576
|
+
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; albumId: string } | null>(null);
|
|
577
|
+
const dropHLRef = useRef<HTMLElement | null>(null);
|
|
578
|
+
const openedTabs = useRef<Map<string, string>>(new Map());
|
|
579
|
+
|
|
580
|
+
const imgMap = useMemo(() => new Map(images.map((i) => [i.id, i])), [images]);
|
|
581
|
+
|
|
582
|
+
// 初始从服务端拉取(等价 Ext.Direct 初始化读 Albums.loadtree / Images.load / Tags.load)。
|
|
583
|
+
// 此后所有读写都走 service;Tree 仅作为相册结构的视觉载体(视觉 owner),状态经 syncAlbums 回流。
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
let alive = true;
|
|
586
|
+
(async () => {
|
|
587
|
+
const [al, im, tg] = await Promise.all([svc.getAlbums(), svc.getImages(), svc.getTags()]);
|
|
588
|
+
if (!alive) return;
|
|
589
|
+
albumCounter.current = al.length;
|
|
590
|
+
setAlbums(al);
|
|
591
|
+
setImages(im);
|
|
592
|
+
setTags(tg);
|
|
593
|
+
setLoading(false);
|
|
594
|
+
})().catch((err) => {
|
|
595
|
+
// 服务端异常不应白屏,至少渲染空壳
|
|
596
|
+
console.error('[ImageOrganizer] initial load failed:', err);
|
|
597
|
+
setLoading(false);
|
|
598
|
+
});
|
|
599
|
+
return () => { alive = false; };
|
|
600
|
+
}, [svc]);
|
|
601
|
+
|
|
602
|
+
// 仅在数据加载完成后挂载一次相册树,保证节点来自服务端(避免受初始空 state 影响)
|
|
603
|
+
const initialNodes = useMemo(() => albumsToNodes(albums, imgMap), [albums, imgMap]);
|
|
604
|
+
|
|
605
|
+
const syncAlbums = useCallback(() => {
|
|
606
|
+
const nodes = treeRef.current?.getNodes() ?? [];
|
|
607
|
+
const next = nodesToAlbums(nodes);
|
|
608
|
+
setAlbums(next);
|
|
609
|
+
onAlbumsChange?.(next);
|
|
610
|
+
}, [onAlbumsChange]);
|
|
611
|
+
|
|
612
|
+
const handleNewAlbum = (): string => {
|
|
613
|
+
albumCounter.current += 1;
|
|
614
|
+
const id = `album-${Date.now()}-${albumCounter.current}`;
|
|
615
|
+
const text = `${albumNamePrefix}${albumCounter.current}`;
|
|
616
|
+
treeRef.current?.appendChild(null, { id, text, cls: 'album-node', expandable: true });
|
|
617
|
+
svc.createAlbum({ id, name: text }).catch((e) => console.error('[ImageOrganizer] createAlbum failed:', e));
|
|
618
|
+
requestAnimationFrame(syncAlbums);
|
|
619
|
+
requestAnimationFrame(() => treeRef.current?.startEdit(id));
|
|
620
|
+
return id;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// ---- 拖图入册(HTML5 drop,等价 onNotifyDrop/addToAlbum) ----
|
|
624
|
+
const clearDropHL = () => {
|
|
625
|
+
if (dropHLRef.current) {
|
|
626
|
+
// 用原版 .x-tree-node-over(xtheme 已定义高亮背景),不写死颜色
|
|
627
|
+
dropHLRef.current.classList.remove('x-tree-node-over');
|
|
628
|
+
dropHLRef.current = null;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const onTreeDragOver = (e: React.DragEvent) => {
|
|
632
|
+
const el = (e.target as HTMLElement).closest('[data-node-id]') as HTMLElement | null;
|
|
633
|
+
if (!el) return;
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
if (dropHLRef.current !== el) {
|
|
636
|
+
clearDropHL();
|
|
637
|
+
el.classList.add('x-tree-node-over');
|
|
638
|
+
dropHLRef.current = el;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const onTreeDrop = (e: React.DragEvent) => {
|
|
642
|
+
e.preventDefault();
|
|
643
|
+
const el = (e.target as HTMLElement).closest('[data-node-id]') as HTMLElement | null;
|
|
644
|
+
clearDropHL();
|
|
645
|
+
if (!el) return;
|
|
646
|
+
const albumId = el.getAttribute('data-node-id')!;
|
|
647
|
+
let ids: string[] = [];
|
|
648
|
+
const raw = e.dataTransfer.getData(IO_IMAGE_LIST_MIME);
|
|
649
|
+
if (raw) {
|
|
650
|
+
try { ids = JSON.parse(raw); } catch { ids = []; }
|
|
651
|
+
}
|
|
652
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
653
|
+
const single = e.dataTransfer.getData('text/plain');
|
|
654
|
+
if (single) ids = [single];
|
|
655
|
+
}
|
|
656
|
+
const imgs = ids.map((id) => imgMap.get(id)).filter((x): x is ImgItem => Boolean(x));
|
|
657
|
+
if (imgs.length === 0) return;
|
|
658
|
+
// 幂等:跳过已在该相册内的图片,避免重复 leaf key(等价原始 addToAlbum 去重)
|
|
659
|
+
const existing = new Set(
|
|
660
|
+
(treeRef.current?.getNode(albumId)?.children ?? [])
|
|
661
|
+
.map((c) => parseLeaf(c.id))
|
|
662
|
+
.filter((x): x is string => Boolean(x)),
|
|
663
|
+
);
|
|
664
|
+
const fresh = imgs.filter((img) => !existing.has(img.id));
|
|
665
|
+
if (fresh.length === 0) return;
|
|
666
|
+
fresh.forEach((img) => {
|
|
667
|
+
treeRef.current?.appendChild(albumId, {
|
|
668
|
+
id: leafId(albumId, img.id),
|
|
669
|
+
text: img.name,
|
|
670
|
+
icon: thumbUrl(img),
|
|
671
|
+
cls: 'image-node',
|
|
672
|
+
});
|
|
673
|
+
onImageDropped?.(img, albumId);
|
|
674
|
+
});
|
|
675
|
+
requestAnimationFrame(syncAlbums);
|
|
676
|
+
svc.addImagesToAlbum(fresh.map((i) => i.id), albumId)
|
|
677
|
+
.catch((err) => console.error('[ImageOrganizer] addImagesToAlbum failed:', err));
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
// ---- 相册右键菜单(Remove / Rename) ----
|
|
681
|
+
const ctxItems: MenuItem[] = ctxMenu
|
|
682
|
+
? [
|
|
683
|
+
{ text: 'Rename', handler: () => { treeRef.current?.startEdit(ctxMenu.albumId); setCtxMenu(null); } },
|
|
684
|
+
{
|
|
685
|
+
text: 'Remove',
|
|
686
|
+
handler: () => {
|
|
687
|
+
treeRef.current?.removeNode(ctxMenu.albumId);
|
|
688
|
+
svc.deleteAlbum(ctxMenu.albumId)
|
|
689
|
+
.catch((err) => console.error('[ImageOrganizer] deleteAlbum failed:', err));
|
|
690
|
+
requestAnimationFrame(syncAlbums);
|
|
691
|
+
setCtxMenu(null);
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
{ separator: true, text: '' },
|
|
695
|
+
{ text: 'Close', handler: () => setCtxMenu(null) },
|
|
696
|
+
]
|
|
697
|
+
: [];
|
|
698
|
+
|
|
699
|
+
// ---- 打开相册过滤 Tab ----
|
|
700
|
+
const openAlbumTab = (albumId: string) => {
|
|
701
|
+
const logical = `album:${albumId}`;
|
|
702
|
+
const existing = openedTabs.current.get(logical);
|
|
703
|
+
if (existing) { tabRef.current?.activate(existing); return; }
|
|
704
|
+
const a = albums.find((x) => x.id === albumId);
|
|
705
|
+
const key = tabRef.current?.add(
|
|
706
|
+
{
|
|
707
|
+
id: `tab-${logical}`,
|
|
708
|
+
title: a?.name ?? 'Album',
|
|
709
|
+
closable: true,
|
|
710
|
+
content: (
|
|
711
|
+
<AlbumTabContent
|
|
712
|
+
albumId={albumId}
|
|
713
|
+
stateRef={stateRef}
|
|
714
|
+
allTags={tags}
|
|
715
|
+
onOpenImage={openImageTab}
|
|
716
|
+
onRenameImage={renameImage}
|
|
717
|
+
onRemoveImage={removeImage}
|
|
718
|
+
/>
|
|
719
|
+
),
|
|
720
|
+
},
|
|
721
|
+
`tab-${logical}`,
|
|
722
|
+
true,
|
|
723
|
+
);
|
|
724
|
+
if (key) openedTabs.current.set(logical, key);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// ---- 打开单图 Tab ----
|
|
728
|
+
const openImageTab = (img: ImgItem) => {
|
|
729
|
+
const logical = `img:${img.id}`;
|
|
730
|
+
const existing = openedTabs.current.get(logical);
|
|
731
|
+
if (existing) { tabRef.current?.activate(existing); return; }
|
|
732
|
+
const key = tabRef.current?.add(
|
|
733
|
+
{
|
|
734
|
+
id: `tab-${logical}`,
|
|
735
|
+
title: img.name,
|
|
736
|
+
closable: true,
|
|
737
|
+
content: <ImageView imgId={img.id} stateRef={stateRef} allTags={tags} onAddTag={addTag} onRemoveTag={removeTag} />,
|
|
738
|
+
},
|
|
739
|
+
`tab-${logical}`,
|
|
740
|
+
true,
|
|
741
|
+
);
|
|
742
|
+
if (key) openedTabs.current.set(logical, key);
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const renameImage = (img: ImgItem, oldName: string) => {
|
|
746
|
+
setImages((prev) => prev.map((i) => (i.id === img.id ? img : i)));
|
|
747
|
+
svc.renameImage(img.id, img.name)
|
|
748
|
+
.catch((err) => console.error('[ImageOrganizer] renameImage failed:', err));
|
|
749
|
+
onImageRenamed?.(img, oldName);
|
|
750
|
+
onImagesChange?.(stateRef.current.images);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const removeImage = (img: ImgItem) => {
|
|
754
|
+
// 先从各相册树节点摘除该图片叶子,再删本体
|
|
755
|
+
const myAlbums = albums.filter((a) => a.imageIds.includes(img.id));
|
|
756
|
+
myAlbums.forEach((a) => treeRef.current?.removeNode(leafId(a.id, img.id)));
|
|
757
|
+
setImages((prev) => prev.filter((i) => i.id !== img.id));
|
|
758
|
+
requestAnimationFrame(syncAlbums);
|
|
759
|
+
svc.deleteImages([img.id])
|
|
760
|
+
.catch((err) => console.error('[ImageOrganizer] deleteImages failed:', err));
|
|
761
|
+
onImagesChange?.(stateRef.current.images);
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const addTag = (imgId: string, tag: string) => {
|
|
765
|
+
svc.tagImage([imgId], tag)
|
|
766
|
+
.then(() => svc.getTags())
|
|
767
|
+
.then(setTags)
|
|
768
|
+
.catch((err) => console.error('[ImageOrganizer] tagImage failed:', err));
|
|
769
|
+
setImages((prev) => prev.map((i) =>
|
|
770
|
+
i.id === imgId ? { ...i, tags: Array.from(new Set([...(i.tags ?? []), tag])) } : i,
|
|
771
|
+
));
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const removeTag = (imgId: string, tag: string) => {
|
|
775
|
+
svc.untagImage(imgId, tag)
|
|
776
|
+
.catch((err) => console.error('[ImageOrganizer] untagImage failed:', err));
|
|
777
|
+
setImages((prev) => prev.map((i) =>
|
|
778
|
+
i.id === imgId ? { ...i, tags: (i.tags ?? []).filter((t) => t !== tag) } : i,
|
|
779
|
+
));
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// ---- 上传桩(替代 SWFUpload,走 service.uploadImage) ----
|
|
783
|
+
const handleFiles = async (files: FileList | File[]) => {
|
|
784
|
+
const list = Array.from(files);
|
|
785
|
+
const added: ImgItem[] = [];
|
|
786
|
+
for (const f of list) {
|
|
787
|
+
try {
|
|
788
|
+
const item = await svc.uploadImage(f);
|
|
789
|
+
added.push(item);
|
|
790
|
+
onImageUploaded?.(item);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
console.error('[ImageOrganizer] uploadImage failed:', err);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (added.length) {
|
|
796
|
+
setImages((prev) => [...prev, ...added]);
|
|
797
|
+
onImagesChange?.(stateRef.current.images);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
useImperativeHandle(ref, () => ({
|
|
802
|
+
addAlbum: (text?: string) => {
|
|
803
|
+
albumCounter.current += 1;
|
|
804
|
+
const id = `album-${Date.now()}-${albumCounter.current}`;
|
|
805
|
+
const t = text ?? `${albumNamePrefix}${albumCounter.current}`;
|
|
806
|
+
treeRef.current?.appendChild(null, { id, text: t, cls: 'album-node', expandable: true });
|
|
807
|
+
svc.createAlbum({ id, name: t }).catch((e) => console.error('[ImageOrganizer] createAlbum failed:', e));
|
|
808
|
+
requestAnimationFrame(syncAlbums);
|
|
809
|
+
requestAnimationFrame(() => treeRef.current?.startEdit(id));
|
|
810
|
+
return id;
|
|
811
|
+
},
|
|
812
|
+
addImage: (image: ImgItem) => {
|
|
813
|
+
setImages((prev) => [...prev, image]);
|
|
814
|
+
onImagesChange?.(stateRef.current.images);
|
|
815
|
+
},
|
|
816
|
+
uploadFiles: (files) => { void handleFiles(files); },
|
|
817
|
+
getAlbums: () => stateRef.current.albums,
|
|
818
|
+
getImages: () => stateRef.current.images,
|
|
819
|
+
}));
|
|
820
|
+
|
|
821
|
+
const tabItems = [
|
|
822
|
+
{
|
|
823
|
+
id: 'albums',
|
|
824
|
+
title: 'Albums',
|
|
825
|
+
content: loading ? null : <AlbumsView stateRef={stateRef} onOpenAlbum={openAlbumTab} />,
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
id: 'all',
|
|
829
|
+
title: 'All Photos',
|
|
830
|
+
content: loading ? null : (
|
|
831
|
+
<ThumbPanel
|
|
832
|
+
images={images}
|
|
833
|
+
tagOptions={tags.map((t) => t.text)}
|
|
834
|
+
onOpenImage={openImageTab}
|
|
835
|
+
onRenameImage={renameImage}
|
|
836
|
+
onRemoveImage={removeImage}
|
|
837
|
+
/>
|
|
838
|
+
),
|
|
839
|
+
},
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
return (
|
|
843
|
+
<div
|
|
844
|
+
className={['x-panel', 'x-img-organizer', className].filter(Boolean).join(' ')}
|
|
845
|
+
style={{ width, height, overflow: 'hidden', ...style }}
|
|
846
|
+
>
|
|
847
|
+
{/* 外壳对齐原版 examples/image-organizer/imgorg/App.js:layout:'border',
|
|
848
|
+
west(相册树, split:true 可拖拽, margins '5 0 5 5', tbar 挂 Add Album/Upload) +
|
|
849
|
+
center(TabPanel, margins '5 5 5 0')。分隔条与 5px 间距由 BorderLayout 原生给出,
|
|
850
|
+
与 Organizer 组件同源,不再手写 flex + borderRight。 */}
|
|
851
|
+
<BorderLayout
|
|
852
|
+
width={width}
|
|
853
|
+
height={height}
|
|
854
|
+
regions={[
|
|
855
|
+
{
|
|
856
|
+
// 左:相册树(等价原版 App.js west region)
|
|
857
|
+
region: 'west',
|
|
858
|
+
width: 180,
|
|
859
|
+
split: true,
|
|
860
|
+
minSize: 120,
|
|
861
|
+
maxSize: 400,
|
|
862
|
+
title: 'My Albums',
|
|
863
|
+
margins: '5 0 5 5',
|
|
864
|
+
cmargins: '0 0 0 0',
|
|
865
|
+
collapsible: true,
|
|
866
|
+
collapseMode: 'mini',
|
|
867
|
+
tbar: (
|
|
868
|
+
<Toolbar
|
|
869
|
+
items={[
|
|
870
|
+
{ text: newAlbumText, handler: handleNewAlbum },
|
|
871
|
+
{ text: uploadText, handler: () => fileInputRef.current?.click() },
|
|
872
|
+
]}
|
|
873
|
+
/>
|
|
874
|
+
),
|
|
875
|
+
children: (
|
|
876
|
+
<div
|
|
877
|
+
style={{ height: '100%', overflow: 'auto', position: 'relative' }}
|
|
878
|
+
onDragOver={onTreeDragOver}
|
|
879
|
+
onDrop={onTreeDrop}
|
|
880
|
+
onContextMenu={(e) => {
|
|
881
|
+
const node = (e.target as HTMLElement).closest('[data-node-id]') as HTMLElement | null;
|
|
882
|
+
const id = node?.getAttribute('data-node-id');
|
|
883
|
+
const albumNode = node?.querySelector('.x-tree-node-el');
|
|
884
|
+
if (id && albumNode && albumNode.className.includes('album-node')) {
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
setCtxMenu({ x: e.clientX, y: e.clientY, albumId: id });
|
|
887
|
+
}
|
|
888
|
+
}}
|
|
889
|
+
>
|
|
890
|
+
{loading ? (
|
|
891
|
+
<div style={{ padding: 10, fontSize: 12 }}>Loading…</div>
|
|
892
|
+
) : (
|
|
893
|
+
<TreePanel
|
|
894
|
+
ref={treeRef}
|
|
895
|
+
nodes={initialNodes}
|
|
896
|
+
rootVisible={false}
|
|
897
|
+
editable
|
|
898
|
+
enableNodeReorder
|
|
899
|
+
onSelect={(node) => {
|
|
900
|
+
// 对齐原版 examples/image-organizer/imgorg/App.js:95-96,184-186
|
|
901
|
+
// 的 album 树 click 监听:点击相册节点 → 右侧切换/打开该相册 Tab;
|
|
902
|
+
// 点击图片叶子 → 打开该图片 Tab。展开(expand)本身只展开子树,
|
|
903
|
+
// 不触发切换(与原生 Ext 同名交互一致)。
|
|
904
|
+
if (node.cls?.includes('album-node')) {
|
|
905
|
+
openAlbumTab(node.id);
|
|
906
|
+
} else if (!node.children?.length) {
|
|
907
|
+
const imgId = parseLeaf(node.id);
|
|
908
|
+
const img = imgId ? images.find((x) => x.id === imgId) : null;
|
|
909
|
+
if (img) openImageTab(img);
|
|
910
|
+
}
|
|
911
|
+
}}
|
|
912
|
+
onRename={(id, text) => {
|
|
913
|
+
svc.renameAlbum(id, text)
|
|
914
|
+
.catch((err) => console.error('[ImageOrganizer] renameAlbum failed:', err));
|
|
915
|
+
syncAlbums();
|
|
916
|
+
}}
|
|
917
|
+
/>
|
|
918
|
+
)}
|
|
919
|
+
</div>
|
|
920
|
+
),
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
// 右:TabPanel(等价原版 App.js center region,Albums / All Photos 标签)
|
|
924
|
+
region: 'center',
|
|
925
|
+
margins: '5 5 5 0',
|
|
926
|
+
children: (
|
|
927
|
+
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
928
|
+
<TabPanel ref={tabRef} items={tabItems} activeTab={0} height="100%" />
|
|
929
|
+
</div>
|
|
930
|
+
),
|
|
931
|
+
},
|
|
932
|
+
]}
|
|
933
|
+
/>
|
|
934
|
+
{/* 隐藏的文件选择器:Upload 按钮触发(等价原始 SWFUpload 选择文件桩),常驻 DOM 供 ref 调用 */}
|
|
935
|
+
<input
|
|
936
|
+
ref={fileInputRef}
|
|
937
|
+
type="file"
|
|
938
|
+
multiple
|
|
939
|
+
accept="image/*"
|
|
940
|
+
style={{ display: 'none' }}
|
|
941
|
+
onChange={(e) => {
|
|
942
|
+
if (e.target.files && e.target.files.length) void handleFiles(e.target.files);
|
|
943
|
+
e.target.value = '';
|
|
944
|
+
}}
|
|
945
|
+
/>
|
|
946
|
+
{ctxMenu &&
|
|
947
|
+
createPortal(
|
|
948
|
+
<div style={{ position: 'absolute', left: ctxMenu.x, top: ctxMenu.y, zIndex: 15000 }}>
|
|
949
|
+
<Menu items={ctxItems} />
|
|
950
|
+
</div>,
|
|
951
|
+
document.body,
|
|
952
|
+
)}
|
|
953
|
+
</div>
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
/** 相册过滤 Tab 内容(读取 stateRef 保证实时) */
|
|
958
|
+
const AlbumTabContent: React.FC<{
|
|
959
|
+
albumId: string;
|
|
960
|
+
stateRef: React.MutableRefObject<{ images: ImgItem[]; albums: AlbumModel[] }>;
|
|
961
|
+
allTags: TagModel[];
|
|
962
|
+
onOpenImage: (img: ImgItem) => void;
|
|
963
|
+
onRenameImage: (img: ImgItem, oldName: string) => void;
|
|
964
|
+
onRemoveImage: (img: ImgItem) => void;
|
|
965
|
+
}> = ({ albumId, stateRef, allTags, onOpenImage, onRenameImage, onRemoveImage }) => {
|
|
966
|
+
const album = stateRef.current.albums.find((a) => a.id === albumId);
|
|
967
|
+
const imgs = useMemo(() => {
|
|
968
|
+
const set = new Set(album?.imageIds ?? []);
|
|
969
|
+
return stateRef.current.images.filter((i) => set.has(i.id));
|
|
970
|
+
}, [album?.imageIds, stateRef.current.images]);
|
|
971
|
+
return (
|
|
972
|
+
<ThumbPanel
|
|
973
|
+
images={imgs}
|
|
974
|
+
tagOptions={allTags.map((t) => t.text)}
|
|
975
|
+
onOpenImage={onOpenImage}
|
|
976
|
+
onRenameImage={onRenameImage}
|
|
977
|
+
onRemoveImage={onRemoveImage}
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
ImageOrganizer.displayName = 'ImageOrganizer';
|
|
983
|
+
|
|
984
|
+
export default ImageOrganizer;
|