@huyooo/file-explorer-frontend-react 0.4.2

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 (46) hide show
  1. package/dist/index.css +1740 -0
  2. package/dist/index.css.map +1 -0
  3. package/dist/index.d.ts +562 -0
  4. package/dist/index.js +3453 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/style.css +3 -0
  7. package/package.json +58 -0
  8. package/src/components/Breadcrumb.css +61 -0
  9. package/src/components/Breadcrumb.tsx +38 -0
  10. package/src/components/CompressDialog.css +264 -0
  11. package/src/components/CompressDialog.tsx +222 -0
  12. package/src/components/ContextMenu.css +155 -0
  13. package/src/components/ContextMenu.tsx +375 -0
  14. package/src/components/FileGrid.css +267 -0
  15. package/src/components/FileGrid.tsx +277 -0
  16. package/src/components/FileIcon.css +41 -0
  17. package/src/components/FileIcon.tsx +86 -0
  18. package/src/components/FileInfoDialog.css +252 -0
  19. package/src/components/FileInfoDialog.tsx +202 -0
  20. package/src/components/FileList.css +226 -0
  21. package/src/components/FileList.tsx +228 -0
  22. package/src/components/FileListView.css +36 -0
  23. package/src/components/FileListView.tsx +355 -0
  24. package/src/components/FileSidebar.css +94 -0
  25. package/src/components/FileSidebar.tsx +66 -0
  26. package/src/components/ProgressDialog.css +211 -0
  27. package/src/components/ProgressDialog.tsx +183 -0
  28. package/src/components/SortIndicator.css +7 -0
  29. package/src/components/SortIndicator.tsx +19 -0
  30. package/src/components/StatusBar.css +20 -0
  31. package/src/components/StatusBar.tsx +21 -0
  32. package/src/components/Toolbar.css +150 -0
  33. package/src/components/Toolbar.tsx +127 -0
  34. package/src/components/Window.css +246 -0
  35. package/src/components/Window.tsx +335 -0
  36. package/src/hooks/useApplicationIcon.ts +80 -0
  37. package/src/hooks/useDragAndDrop.ts +104 -0
  38. package/src/hooks/useMediaPlayer.ts +164 -0
  39. package/src/hooks/useSelection.ts +112 -0
  40. package/src/hooks/useWindowDrag.ts +60 -0
  41. package/src/hooks/useWindowResize.ts +126 -0
  42. package/src/index.css +3 -0
  43. package/src/index.ts +34 -0
  44. package/src/types/index.ts +274 -0
  45. package/src/utils/fileTypeIcon.ts +309 -0
  46. package/src/utils/folderTypeIcon.ts +132 -0
@@ -0,0 +1,155 @@
1
+ /* container 不拦截事件,让右键事件可以穿透到下层文件列表 */
2
+ .context-menu-container {
3
+ position: fixed;
4
+ inset: 0;
5
+ z-index: 9999;
6
+ pointer-events: none;
7
+ }
8
+
9
+ .context-menu {
10
+ position: fixed;
11
+ z-index: 10000;
12
+ width: 220px; /* 固定宽度,与 calculateMenuPosition 中的 MENU_WIDTH 一致 */
13
+ background: rgba(255, 255, 255, 0.95);
14
+ backdrop-filter: blur(24px);
15
+ border: 1px solid rgba(229, 231, 233, 0.5);
16
+ border-radius: 0.5rem;
17
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
18
+ padding: 0.25rem 0;
19
+ font-size: 0.875rem;
20
+ user-select: none;
21
+ animation: fade-in 100ms ease-out, zoom-in 100ms ease-out;
22
+ pointer-events: auto;
23
+ }
24
+
25
+ @keyframes fade-in {
26
+ from { opacity: 0; }
27
+ to { opacity: 1; }
28
+ }
29
+
30
+ @keyframes zoom-in {
31
+ from { transform: scale(0.95); }
32
+ to { transform: scale(1); }
33
+ }
34
+
35
+ .context-menu-item {
36
+ width: 100%;
37
+ text-align: left;
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 0.5rem;
41
+ padding: 0.375rem 1rem;
42
+ color: rgb(55, 65, 81);
43
+ transition: all 200ms;
44
+ border: none;
45
+ background: transparent;
46
+ cursor: pointer;
47
+ }
48
+
49
+ .context-menu-item:hover {
50
+ background: rgb(59, 130, 246);
51
+ color: white;
52
+ }
53
+
54
+ .context-menu-item:hover .context-menu-item-icon {
55
+ color: white;
56
+ }
57
+
58
+ .context-menu-item:hover .context-menu-item-shortcut {
59
+ color: rgba(255, 255, 255, 0.7);
60
+ }
61
+
62
+ .context-menu-item--disabled {
63
+ opacity: 0.5;
64
+ cursor: not-allowed;
65
+ }
66
+
67
+ .context-menu-item--disabled:hover {
68
+ background: transparent;
69
+ color: rgb(55, 65, 81);
70
+ }
71
+
72
+ .context-menu-item--disabled:hover .context-menu-item-icon {
73
+ color: rgb(107, 114, 128);
74
+ }
75
+
76
+ .context-menu-item--danger {
77
+ color: rgb(220, 38, 38);
78
+ }
79
+
80
+ .context-menu-item--danger:hover {
81
+ background: rgb(220, 38, 38);
82
+ color: white;
83
+ }
84
+
85
+ .context-menu-item--danger .context-menu-item-icon {
86
+ color: rgb(220, 38, 38);
87
+ }
88
+
89
+ .context-menu-item--danger:hover .context-menu-item-icon {
90
+ color: white;
91
+ }
92
+
93
+ .context-menu-item-icon {
94
+ color: rgb(107, 114, 128);
95
+ flex-shrink: 0;
96
+ }
97
+
98
+ .context-menu-item-label {
99
+ flex: 1;
100
+ }
101
+
102
+ .context-menu-item-shortcut {
103
+ color: rgb(156, 163, 175);
104
+ font-size: 0.6875rem;
105
+ }
106
+
107
+ .context-menu-separator {
108
+ height: 1px;
109
+ background: rgb(229, 231, 233);
110
+ margin: 0.25rem 0.5rem;
111
+ }
112
+
113
+ .context-menu-item--has-children {
114
+ position: relative;
115
+ }
116
+
117
+ .context-menu-item--active {
118
+ background: rgb(59, 130, 246);
119
+ color: white;
120
+ }
121
+
122
+ .context-menu-item--active .context-menu-item-icon {
123
+ color: white;
124
+ }
125
+
126
+ .context-menu-item--active .context-menu-item-shortcut {
127
+ color: rgba(255, 255, 255, 0.7);
128
+ }
129
+
130
+ .context-menu-item-check {
131
+ color: rgb(107, 114, 128);
132
+ flex-shrink: 0;
133
+ margin-left: auto;
134
+ }
135
+
136
+ .context-menu-item:hover .context-menu-item-check,
137
+ .context-menu-item--active .context-menu-item-check {
138
+ color: white;
139
+ }
140
+
141
+ .context-menu-item-arrow {
142
+ color: rgb(156, 163, 175);
143
+ flex-shrink: 0;
144
+ margin-left: auto;
145
+ }
146
+
147
+ .context-menu-item:hover .context-menu-item-arrow,
148
+ .context-menu-item--active .context-menu-item-arrow {
149
+ color: white;
150
+ }
151
+
152
+ .context-menu-submenu {
153
+ z-index: 10001;
154
+ }
155
+
@@ -0,0 +1,375 @@
1
+ import { createPortal } from 'react-dom';
2
+ import { useMemo, useEffect, useRef, useCallback, useState } from 'react';
3
+ import { Icon } from '@iconify/react';
4
+ import type { ContextMenuItem } from '../types';
5
+ import './ContextMenu.css';
6
+
7
+ interface ContextMenuProps {
8
+ visible: boolean;
9
+ x: number;
10
+ y: number;
11
+ options: ContextMenuItem[];
12
+ onClose?: () => void;
13
+ onSelect?: (option: ContextMenuItem) => void;
14
+ }
15
+
16
+ /** 边距常量 */
17
+ const MARGIN = 8;
18
+ /** 菜单固定宽度 */
19
+ const MENU_WIDTH = 220;
20
+ /** 菜单项高度 */
21
+ const MENU_ITEM_HEIGHT = 32;
22
+ /** 分隔线高度 */
23
+ const SEPARATOR_HEIGHT = 9;
24
+ /** 菜单内边距 */
25
+ const MENU_PADDING = 8;
26
+ /** 子菜单与父菜单的间距 */
27
+ const SUBMENU_GAP = 0;
28
+
29
+ /**
30
+ * 估算菜单高度(根据菜单项数量)
31
+ */
32
+ function estimateMenuHeight(items: ContextMenuItem[]): number {
33
+ let height = MENU_PADDING;
34
+ for (const item of items) {
35
+ height += item.separator ? SEPARATOR_HEIGHT : MENU_ITEM_HEIGHT;
36
+ }
37
+ return height;
38
+ }
39
+
40
+ /**
41
+ * 计算自适应菜单位置
42
+ * 使用固定宽度,只需测量高度,简化计算逻辑
43
+ */
44
+ function calculateMenuPosition(
45
+ clickX: number,
46
+ clickY: number,
47
+ menuHeight: number
48
+ ): { x: number; y: number } {
49
+ const viewportWidth = window.innerWidth;
50
+ const viewportHeight = window.innerHeight;
51
+
52
+ let adjustedX = clickX;
53
+ let adjustedY = clickY;
54
+
55
+ // 水平方向调整(使用固定宽度)
56
+ const spaceRight = viewportWidth - clickX;
57
+ const spaceLeft = clickX;
58
+
59
+ // 如果右边空间不足,尝试在左边显示
60
+ if (spaceRight < MENU_WIDTH + MARGIN) {
61
+ // 左边空间足够,在左边显示
62
+ if (spaceLeft >= MENU_WIDTH + MARGIN) {
63
+ adjustedX = clickX - MENU_WIDTH;
64
+ } else {
65
+ // 左右空间都不足,选择空间更大的一边,并留出边距
66
+ if (spaceRight > spaceLeft) {
67
+ adjustedX = viewportWidth - MENU_WIDTH - MARGIN;
68
+ } else {
69
+ adjustedX = MARGIN;
70
+ }
71
+ }
72
+ }
73
+
74
+ // 垂直方向调整
75
+ const spaceBottom = viewportHeight - clickY;
76
+ const spaceTop = clickY;
77
+
78
+ // 如果下边空间不足,尝试在上边显示
79
+ if (spaceBottom < menuHeight + MARGIN) {
80
+ // 上边空间足够,在上边显示
81
+ if (spaceTop >= menuHeight + MARGIN) {
82
+ adjustedY = clickY - menuHeight;
83
+ } else {
84
+ // 上下空间都不足,选择空间更大的一边,并留出边距
85
+ if (spaceBottom > spaceTop) {
86
+ adjustedY = viewportHeight - menuHeight - MARGIN;
87
+ } else {
88
+ adjustedY = MARGIN;
89
+ }
90
+ }
91
+ }
92
+
93
+ // 最终边界检查,确保不会超出视口
94
+ adjustedX = Math.max(MARGIN, Math.min(adjustedX, viewportWidth - MENU_WIDTH - MARGIN));
95
+ adjustedY = Math.max(MARGIN, Math.min(adjustedY, viewportHeight - menuHeight - MARGIN));
96
+
97
+ return { x: adjustedX, y: adjustedY };
98
+ }
99
+
100
+ /**
101
+ * 计算二级菜单位置(Windows/Mac 标准行为:优先右侧,空间不足时左侧)
102
+ * @param parentRect 父菜单项的边界矩形
103
+ * @param submenuHeight 子菜单高度
104
+ * @returns 子菜单位置
105
+ */
106
+ function calculateSubmenuPosition(
107
+ parentRect: DOMRect,
108
+ submenuHeight: number
109
+ ): { x: number; y: number } {
110
+ const viewportWidth = window.innerWidth;
111
+ const viewportHeight = window.innerHeight;
112
+
113
+ // 计算可用空间
114
+ const spaceRight = viewportWidth - parentRect.right;
115
+ const spaceLeft = parentRect.left;
116
+
117
+ let submenuX: number;
118
+ let submenuY: number;
119
+
120
+ // 水平方向:优先右侧,空间不足时左侧(Windows/Mac 标准行为)
121
+ if (spaceRight >= MENU_WIDTH + SUBMENU_GAP + MARGIN) {
122
+ // 右侧有足够空间,在右侧显示(紧贴父菜单右边缘)
123
+ submenuX = parentRect.right + SUBMENU_GAP;
124
+ } else if (spaceLeft >= MENU_WIDTH + SUBMENU_GAP + MARGIN) {
125
+ // 右侧空间不足,左侧有足够空间,在左侧显示(紧贴父菜单左边缘)
126
+ submenuX = parentRect.left - MENU_WIDTH - SUBMENU_GAP;
127
+ } else {
128
+ // 左右空间都不足,选择空间更大的一边
129
+ if (spaceRight > spaceLeft) {
130
+ submenuX = viewportWidth - MENU_WIDTH - MARGIN;
131
+ } else {
132
+ submenuX = MARGIN;
133
+ }
134
+ }
135
+
136
+ // 垂直方向:与父菜单项顶部对齐(Windows/Mac 标准行为)
137
+ submenuY = parentRect.top;
138
+
139
+ // 如果子菜单底部超出视口,向上调整
140
+ if (submenuY + submenuHeight > viewportHeight - MARGIN) {
141
+ submenuY = viewportHeight - submenuHeight - MARGIN;
142
+ }
143
+
144
+ // 如果子菜单顶部超出视口,向下调整
145
+ if (submenuY < MARGIN) {
146
+ submenuY = MARGIN;
147
+ }
148
+
149
+ return { x: submenuX, y: submenuY };
150
+ }
151
+
152
+ export function ContextMenu({
153
+ visible,
154
+ x,
155
+ y,
156
+ options,
157
+ onClose,
158
+ onSelect,
159
+ }: ContextMenuProps) {
160
+ const menuRef = useRef<HTMLDivElement>(null);
161
+ const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
162
+ const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number } | null>(null);
163
+ const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
164
+
165
+ // 使用 useMemo 在渲染前计算位置(使用估算高度,避免位置调整动画)
166
+ const position = useMemo(() => {
167
+ const estimatedHeight = estimateMenuHeight(options);
168
+ return calculateMenuPosition(x, y, estimatedHeight);
169
+ }, [x, y, options]);
170
+
171
+ const menuStyle = useMemo(
172
+ () => ({
173
+ left: `${position.x}px`,
174
+ top: `${position.y}px`,
175
+ }),
176
+ [position]
177
+ );
178
+
179
+ // 获取当前悬停项的子菜单
180
+ const hoveredItem = useMemo(() => {
181
+ if (!hoveredItemId) return null;
182
+ return options.find(opt => opt.id === hoveredItemId && opt.children && opt.children.length > 0);
183
+ }, [hoveredItemId, options]);
184
+
185
+ // 计算子菜单位置
186
+ useEffect(() => {
187
+ if (!hoveredItemId || !hoveredItem) {
188
+ setSubmenuPosition(null);
189
+ return;
190
+ }
191
+
192
+ const itemEl = itemRefs.current.get(hoveredItemId);
193
+ if (!itemEl) {
194
+ setSubmenuPosition(null);
195
+ return;
196
+ }
197
+
198
+ const rect = itemEl.getBoundingClientRect();
199
+ const submenuHeight = estimateMenuHeight(hoveredItem.children || []);
200
+ const pos = calculateSubmenuPosition(rect, submenuHeight);
201
+ setSubmenuPosition(pos);
202
+ }, [hoveredItemId, hoveredItem]);
203
+
204
+ // 点击外部或按 ESC 键时关闭
205
+ useEffect(() => {
206
+ if (!visible) return;
207
+
208
+ const handleClickOutside = (e: MouseEvent) => {
209
+ const target = e.target as Node;
210
+
211
+ // 检查是否点击在菜单内(包括主菜单和子菜单)
212
+ const menuContainer = document.querySelector('.context-menu-container');
213
+ if (menuContainer && menuContainer.contains(target)) {
214
+ return;
215
+ }
216
+
217
+ onClose?.();
218
+ };
219
+
220
+ const handleEscape = (e: KeyboardEvent) => {
221
+ if (e.key === 'Escape') {
222
+ onClose?.();
223
+ }
224
+ };
225
+
226
+ // 使用 mousedown 来更快响应(Windows/Mac 标准行为)
227
+ document.addEventListener('mousedown', handleClickOutside);
228
+ document.addEventListener('keydown', handleEscape);
229
+
230
+ return () => {
231
+ document.removeEventListener('mousedown', handleClickOutside);
232
+ document.removeEventListener('keydown', handleEscape);
233
+ };
234
+ }, [visible, onClose]);
235
+
236
+ // 重置状态当菜单关闭时
237
+ useEffect(() => {
238
+ if (!visible) {
239
+ setHoveredItemId(null);
240
+ setSubmenuPosition(null);
241
+ }
242
+ }, [visible]);
243
+
244
+ // Iconify 图标渲染函数
245
+ const renderIcon = (iconName: string) => {
246
+ if (!iconName) return null;
247
+ return <Icon icon={iconName} width={16} height={16} className="context-menu-item-icon" />;
248
+ };
249
+
250
+ const handleOptionClick = useCallback(
251
+ (option: ContextMenuItem) => {
252
+ if (option.disabled) return;
253
+
254
+ // 如果有子菜单,不执行 action,只显示子菜单
255
+ if (option.children && option.children.length > 0) {
256
+ return;
257
+ }
258
+
259
+ if (option.action) {
260
+ option.action();
261
+ }
262
+ onSelect?.(option);
263
+ onClose?.();
264
+ },
265
+ [onClose, onSelect]
266
+ );
267
+
268
+ // 处理菜单项鼠标进入
269
+ const handleItemMouseEnter = useCallback((option: ContextMenuItem) => {
270
+ if (option.children && option.children.length > 0) {
271
+ setHoveredItemId(option.id);
272
+ } else {
273
+ setHoveredItemId(null);
274
+ }
275
+ }, []);
276
+
277
+ if (!visible) return null;
278
+
279
+ return createPortal(
280
+ <div className="context-menu-container">
281
+ {/* 主菜单 - 不使用 overlay,通过 document mousedown 事件来关闭菜单 */}
282
+ <div ref={menuRef} className="context-menu" style={menuStyle}>
283
+ {options.map((option, index) => {
284
+ if (option.separator) {
285
+ return <div key={index} className="context-menu-separator" />;
286
+ }
287
+
288
+ const hasChildren = option.children && option.children.length > 0;
289
+ const isHovered = hoveredItemId === option.id;
290
+
291
+ return (
292
+ <div
293
+ key={option.id || index}
294
+ ref={(el) => {
295
+ if (el) {
296
+ itemRefs.current.set(option.id, el);
297
+ } else {
298
+ itemRefs.current.delete(option.id);
299
+ }
300
+ }}
301
+ className={`context-menu-item ${
302
+ option.disabled ? 'context-menu-item--disabled' : ''
303
+ } ${
304
+ option.danger ? 'context-menu-item--danger' : ''
305
+ } ${
306
+ hasChildren ? 'context-menu-item--has-children' : ''
307
+ } ${
308
+ isHovered ? 'context-menu-item--active' : ''
309
+ }`}
310
+ onClick={() => handleOptionClick(option)}
311
+ onMouseEnter={() => handleItemMouseEnter(option)}
312
+ >
313
+ {option.icon && renderIcon(option.icon)}
314
+ <span className="context-menu-item-label">{option.label}</span>
315
+ {option.checked && (
316
+ <Icon icon="lucide:check" width={14} height={14} className="context-menu-item-check" />
317
+ )}
318
+ {option.shortcut && !hasChildren && (
319
+ <span className="context-menu-item-shortcut">
320
+ {option.shortcut}
321
+ </span>
322
+ )}
323
+ {hasChildren && (
324
+ <Icon icon="lucide:chevron-right" width={14} height={14} className="context-menu-item-arrow" />
325
+ )}
326
+ </div>
327
+ );
328
+ })}
329
+ </div>
330
+
331
+ {/* 二级菜单(独立渲染,避免定位问题) */}
332
+ {hoveredItem && submenuPosition && (
333
+ <div
334
+ className="context-menu context-menu-submenu"
335
+ style={{
336
+ left: `${submenuPosition.x}px`,
337
+ top: `${submenuPosition.y}px`,
338
+ }}
339
+ onMouseEnter={() => setHoveredItemId(hoveredItem.id)}
340
+ onMouseLeave={() => setHoveredItemId(null)}
341
+ >
342
+ {hoveredItem.children!.map((child, childIndex) => {
343
+ if (child.separator) {
344
+ return <div key={`child-sep-${childIndex}`} className="context-menu-separator" />;
345
+ }
346
+
347
+ return (
348
+ <div
349
+ key={child.id || childIndex}
350
+ className={`context-menu-item ${
351
+ child.disabled ? 'context-menu-item--disabled' : ''
352
+ } ${
353
+ child.danger ? 'context-menu-item--danger' : ''
354
+ }`}
355
+ onClick={() => handleOptionClick(child)}
356
+ >
357
+ {child.icon && renderIcon(child.icon)}
358
+ <span className="context-menu-item-label">{child.label}</span>
359
+ {child.checked && (
360
+ <Icon icon="lucide:check" width={14} height={14} className="context-menu-item-check" />
361
+ )}
362
+ {child.shortcut && (
363
+ <span className="context-menu-item-shortcut">
364
+ {child.shortcut}
365
+ </span>
366
+ )}
367
+ </div>
368
+ );
369
+ })}
370
+ </div>
371
+ )}
372
+ </div>,
373
+ document.body
374
+ );
375
+ }