@lokvis/ui-react 0.1.1-beta.0 → 0.2.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/dist/components/AssetPanel.d.ts +2 -2
- package/dist/components/AssetPanel.d.ts.map +1 -1
- package/dist/components/AssetPanel.js +79 -7
- package/dist/components/AssetPanel.js.map +1 -1
- package/dist/components/Canvas.d.ts +9 -1
- package/dist/components/Canvas.d.ts.map +1 -1
- package/dist/components/Canvas.js +39 -2
- package/dist/components/Canvas.js.map +1 -1
- package/dist/components/CommandPalette.d.ts +48 -0
- package/dist/components/CommandPalette.d.ts.map +1 -0
- package/dist/components/CommandPalette.js +154 -0
- package/dist/components/CommandPalette.js.map +1 -0
- package/dist/components/CompareSlider.d.ts +25 -0
- package/dist/components/CompareSlider.d.ts.map +1 -0
- package/dist/components/CompareSlider.js +106 -0
- package/dist/components/CompareSlider.js.map +1 -0
- package/dist/components/DownloadPanel.d.ts +20 -0
- package/dist/components/DownloadPanel.d.ts.map +1 -0
- package/dist/components/DownloadPanel.js +169 -0
- package/dist/components/DownloadPanel.js.map +1 -0
- package/dist/components/ErrorBanner.d.ts +22 -0
- package/dist/components/ErrorBanner.d.ts.map +1 -0
- package/dist/components/ErrorBanner.js +47 -0
- package/dist/components/ErrorBanner.js.map +1 -0
- package/dist/components/ExifPanel.d.ts +26 -0
- package/dist/components/ExifPanel.d.ts.map +1 -0
- package/dist/components/ExifPanel.js +104 -0
- package/dist/components/ExifPanel.js.map +1 -0
- package/dist/components/GlobalDropzone.d.ts +37 -0
- package/dist/components/GlobalDropzone.d.ts.map +1 -0
- package/dist/components/GlobalDropzone.js +154 -0
- package/dist/components/GlobalDropzone.js.map +1 -0
- package/dist/components/HistoryPanel.d.ts +17 -7
- package/dist/components/HistoryPanel.d.ts.map +1 -1
- package/dist/components/HistoryPanel.js +74 -62
- package/dist/components/HistoryPanel.js.map +1 -1
- package/dist/components/Inspector.d.ts.map +1 -1
- package/dist/components/Inspector.js +2 -1
- package/dist/components/Inspector.js.map +1 -1
- package/dist/components/ProgressBar.d.ts +18 -0
- package/dist/components/ProgressBar.d.ts.map +1 -0
- package/dist/components/ProgressBar.js +35 -0
- package/dist/components/ProgressBar.js.map +1 -0
- package/dist/components/StatusBar.d.ts +8 -2
- package/dist/components/StatusBar.d.ts.map +1 -1
- package/dist/components/StatusBar.js +59 -4
- package/dist/components/StatusBar.js.map +1 -1
- package/dist/components/ThemeToggle.d.ts +20 -0
- package/dist/components/ThemeToggle.d.ts.map +1 -0
- package/dist/components/ThemeToggle.js +60 -0
- package/dist/components/ThemeToggle.js.map +1 -0
- package/dist/components/WorkflowEditor.d.ts +24 -0
- package/dist/components/WorkflowEditor.d.ts.map +1 -0
- package/dist/components/WorkflowEditor.js +160 -0
- package/dist/components/WorkflowEditor.js.map +1 -0
- package/dist/components/WorkflowTemplates.d.ts +23 -0
- package/dist/components/WorkflowTemplates.d.ts.map +1 -0
- package/dist/components/WorkflowTemplates.js +32 -0
- package/dist/components/WorkflowTemplates.js.map +1 -0
- package/dist/components/Workspace.d.ts +48 -2
- package/dist/components/Workspace.d.ts.map +1 -1
- package/dist/components/Workspace.js +78 -4
- package/dist/components/Workspace.js.map +1 -1
- package/dist/data/workflow-templates.d.ts +42 -0
- package/dist/data/workflow-templates.d.ts.map +1 -0
- package/dist/data/workflow-templates.js +112 -0
- package/dist/data/workflow-templates.js.map +1 -0
- package/dist/hooks/useDebouncedRun.d.ts +49 -0
- package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
- package/dist/hooks/useDebouncedRun.js +117 -0
- package/dist/hooks/useDebouncedRun.js.map +1 -0
- package/dist/hooks/useLokvis.js +1 -1
- package/dist/hooks/useMediaQuery.d.ts +27 -0
- package/dist/hooks/useMediaQuery.d.ts.map +1 -0
- package/dist/hooks/useMediaQuery.js +41 -0
- package/dist/hooks/useMediaQuery.js.map +1 -0
- package/dist/hooks/useShareLink.d.ts +58 -0
- package/dist/hooks/useShareLink.d.ts.map +1 -0
- package/dist/hooks/useShareLink.js +120 -0
- package/dist/hooks/useShareLink.js.map +1 -0
- package/dist/hooks/useTheme.d.ts +30 -0
- package/dist/hooks/useTheme.d.ts.map +1 -0
- package/dist/hooks/useTheme.js +105 -0
- package/dist/hooks/useTheme.js.map +1 -0
- package/dist/hooks/useWorkflowProgress.d.ts +28 -0
- package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
- package/dist/hooks/useWorkflowProgress.js +25 -0
- package/dist/hooks/useWorkflowProgress.js.map +1 -0
- package/dist/hooks/useWorkflows.d.ts +86 -0
- package/dist/hooks/useWorkflows.d.ts.map +1 -0
- package/dist/hooks/useWorkflows.js +247 -0
- package/dist/hooks/useWorkflows.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/store/assets-slice.d.ts.map +1 -1
- package/dist/store/assets-slice.js +2 -0
- package/dist/store/assets-slice.js.map +1 -1
- package/dist/store/history-slice.d.ts +18 -0
- package/dist/store/history-slice.d.ts.map +1 -0
- package/dist/store/history-slice.js +42 -0
- package/dist/store/history-slice.js.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +3 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/runtime-slice.d.ts +10 -2
- package/dist/store/runtime-slice.d.ts.map +1 -1
- package/dist/store/runtime-slice.js +77 -28
- package/dist/store/runtime-slice.js.map +1 -1
- package/dist/store/types.d.ts +70 -1
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/types.js +10 -0
- package/dist/store/types.js.map +1 -1
- package/dist/store/workflow-slice.d.ts +1 -1
- package/dist/store/workflow-slice.d.ts.map +1 -1
- package/dist/store/workflow-slice.js +128 -9
- package/dist/store/workflow-slice.js.map +1 -1
- package/package.json +9 -6
- package/src/components/AssetPanel.tsx +149 -8
- package/src/components/Canvas.tsx +79 -2
- package/src/components/CommandPalette.tsx +300 -0
- package/src/components/CompareSlider.tsx +186 -0
- package/src/components/DownloadPanel.tsx +252 -0
- package/src/components/ErrorBanner.tsx +95 -0
- package/src/components/ExifPanel.tsx +187 -0
- package/src/components/GlobalDropzone.tsx +220 -0
- package/src/components/HistoryPanel.tsx +231 -104
- package/src/components/Inspector.tsx +4 -0
- package/src/components/ProgressBar.tsx +104 -0
- package/src/components/StatusBar.tsx +124 -5
- package/src/components/ThemeToggle.tsx +122 -0
- package/src/components/WorkflowEditor.tsx +382 -0
- package/src/components/WorkflowTemplates.tsx +101 -0
- package/src/components/Workspace.tsx +205 -8
- package/src/data/workflow-templates.ts +137 -0
- package/src/hooks/useDebouncedRun.ts +151 -0
- package/src/hooks/useLokvis.ts +1 -1
- package/src/hooks/useMediaQuery.ts +53 -0
- package/src/hooks/useShareLink.ts +158 -0
- package/src/hooks/useTheme.ts +116 -0
- package/src/hooks/useWorkflowProgress.ts +41 -0
- package/src/hooks/useWorkflows.ts +326 -0
- package/src/index.ts +32 -0
- package/src/store/assets-slice.ts +2 -0
- package/src/store/history-slice.ts +65 -0
- package/src/store/index.ts +3 -0
- package/src/store/runtime-slice.ts +89 -32
- package/src/store/types.ts +71 -1
- package/src/store/workflow-slice.ts +139 -10
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExifPanel - EXIF 元数据查看面板(W7.4)
|
|
3
|
+
*
|
|
4
|
+
* 监听当前选中的 image asset,通过 runtime.readAssetExif 桥接调用
|
|
5
|
+
* plugin-image 注册的 MetadataReader,展示结构化 EXIF 字段。
|
|
6
|
+
*
|
|
7
|
+
* 架构合规(长期方案):
|
|
8
|
+
* - UI 只依赖 @lokvis/schema(ExifData 类型 + formatExifRows)和 @lokvis/runtime
|
|
9
|
+
* - 不直接依赖 plugin-image / engine-image(五层单向依赖)
|
|
10
|
+
* - ExifData 类型不含 raw 字段(schema 层类型分层),UI 缓存无需手动剔除
|
|
11
|
+
*
|
|
12
|
+
* 设计要点:
|
|
13
|
+
* - 独立可折叠面板(渲染在 Inspector 顶部)
|
|
14
|
+
* - 仅 image 类型 asset 显示;非 image 隐藏整个面板
|
|
15
|
+
* - 缓存:按 assetId 缓存(LRU 上限 16),切换 asset 不重复解析
|
|
16
|
+
* - effect 依赖 selectedAssetId(而非 asset 对象引用),避免 assets 数组
|
|
17
|
+
* 重渲染时触发重复 effect
|
|
18
|
+
* - 取消:effect 重跑/卸载时不更新 state(避免 stale update)
|
|
19
|
+
* - 加载/空态/错误三态明确
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import { Icon } from '@lokvis/ui-core';
|
|
24
|
+
import { formatExifRows, type ExifData } from '@lokvis/schema';
|
|
25
|
+
import { useWorkspaceStore } from '../store/index.js';
|
|
26
|
+
|
|
27
|
+
export interface ExifPanelProps {
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** cache LRU 上限:防止长会话切换大量图片时 Map 无限增长 */
|
|
32
|
+
const EXIF_CACHE_LIMIT = 16;
|
|
33
|
+
|
|
34
|
+
export function ExifPanel({ className = '' }: ExifPanelProps) {
|
|
35
|
+
const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
|
|
36
|
+
const assets = useWorkspaceStore((s) => s.assets);
|
|
37
|
+
const runtime = useWorkspaceStore((s) => s.runtime);
|
|
38
|
+
|
|
39
|
+
const selectedAsset = React.useMemo(
|
|
40
|
+
() => assets.find((a) => a.id === selectedAssetId) ?? null,
|
|
41
|
+
[assets, selectedAssetId]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// 仅 image 类型 asset 显示 EXIF 面板
|
|
45
|
+
const isImage = selectedAsset?.type === 'image';
|
|
46
|
+
// effect 依赖用 id 而非 asset 引用:避免 assets 数组重渲染触发重复 effect
|
|
47
|
+
const selectedAssetIdForEffect = selectedAsset?.id;
|
|
48
|
+
|
|
49
|
+
const [open, setOpen] = React.useState(true);
|
|
50
|
+
const [exif, setExif] = React.useState<ExifData | null>(null);
|
|
51
|
+
const [loading, setLoading] = React.useState(false);
|
|
52
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
// 按 assetId 缓存解析结果,避免切换来回重复解析
|
|
55
|
+
const cacheRef = React.useRef<Map<string, ExifData | null>>(new Map());
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!isImage || !selectedAssetIdForEffect || !runtime) {
|
|
59
|
+
setExif(null);
|
|
60
|
+
setError(null);
|
|
61
|
+
setLoading(false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const assetId = selectedAssetIdForEffect;
|
|
66
|
+
const cache = cacheRef.current;
|
|
67
|
+
|
|
68
|
+
// 命中缓存:同步设置,不发请求(LRU 命中后重排到末尾)
|
|
69
|
+
if (cache.has(assetId)) {
|
|
70
|
+
const cached = cache.get(assetId) ?? null;
|
|
71
|
+
cache.delete(assetId);
|
|
72
|
+
cache.set(assetId, cached);
|
|
73
|
+
setExif(cached);
|
|
74
|
+
setError(null);
|
|
75
|
+
setLoading(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
setLoading(true);
|
|
81
|
+
setError(null);
|
|
82
|
+
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
// 通过 runtime.readAssetExif 桥接(MetadataReader 依赖反转),
|
|
86
|
+
// UI 不直接依赖 plugin-image / engine-image
|
|
87
|
+
const data = await runtime.readAssetExif(assetId);
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
// LRU 上限:超出时删除最旧 entry(Map 第一个,即最近最少命中)
|
|
90
|
+
if (cache.size >= EXIF_CACHE_LIMIT) {
|
|
91
|
+
const oldestKey = cache.keys().next().value;
|
|
92
|
+
if (oldestKey !== undefined) cache.delete(oldestKey);
|
|
93
|
+
}
|
|
94
|
+
cache.set(assetId, data);
|
|
95
|
+
setExif(data);
|
|
96
|
+
setError(null);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (cancelled) return;
|
|
99
|
+
const msg = err instanceof Error ? err.message : 'Failed to read EXIF';
|
|
100
|
+
setError(msg);
|
|
101
|
+
setExif(null);
|
|
102
|
+
} finally {
|
|
103
|
+
if (!cancelled) setLoading(false);
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
cancelled = true;
|
|
109
|
+
};
|
|
110
|
+
}, [isImage, selectedAssetIdForEffect, runtime]);
|
|
111
|
+
|
|
112
|
+
// 非 image 或无选中 asset:不渲染(避免占用布局空间)
|
|
113
|
+
if (!isImage || !selectedAsset) return null;
|
|
114
|
+
|
|
115
|
+
const rows = exif ? formatExifRows(exif) : [];
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={`shrink-0 border-b border-zinc-200 dark:border-zinc-800 ${className}`}
|
|
120
|
+
>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => setOpen(!open)}
|
|
124
|
+
className="flex w-full items-center justify-between px-3 h-10 text-left transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
|
|
125
|
+
aria-expanded={open}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
<Icon
|
|
129
|
+
size={12}
|
|
130
|
+
className={`text-zinc-400 transition-transform ${open ? 'rotate-90' : ''}`}
|
|
131
|
+
>
|
|
132
|
+
<path d="m9 5 7 7-7 7" />
|
|
133
|
+
</Icon>
|
|
134
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
|
135
|
+
EXIF
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
{loading ? (
|
|
139
|
+
<span className="text-[10px] text-zinc-400">loading…</span>
|
|
140
|
+
) : exif ? (
|
|
141
|
+
<span className="font-mono text-[10px] text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30 dark:text-emerald-400 px-1.5 py-0.5 rounded">
|
|
142
|
+
{rows.length} fields
|
|
143
|
+
</span>
|
|
144
|
+
) : error ? (
|
|
145
|
+
<span className="text-[10px] text-red-500">error</span>
|
|
146
|
+
) : (
|
|
147
|
+
<span className="text-[10px] text-zinc-400">none</span>
|
|
148
|
+
)}
|
|
149
|
+
</button>
|
|
150
|
+
|
|
151
|
+
{open && (
|
|
152
|
+
<div className="px-3 pb-3 max-h-60 overflow-y-auto">
|
|
153
|
+
{loading ? (
|
|
154
|
+
<div className="py-4 text-center text-[11px] text-zinc-400">
|
|
155
|
+
Reading EXIF…
|
|
156
|
+
</div>
|
|
157
|
+
) : error ? (
|
|
158
|
+
<div className="py-3 text-center">
|
|
159
|
+
<p className="text-[11px] text-red-500">{error}</p>
|
|
160
|
+
</div>
|
|
161
|
+
) : rows.length === 0 ? (
|
|
162
|
+
<div className="py-4 text-center">
|
|
163
|
+
<p className="text-[11px] text-zinc-400">No EXIF data</p>
|
|
164
|
+
<p className="mt-1 text-[10px] text-zinc-500">
|
|
165
|
+
This image has no embedded metadata
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
) : (
|
|
169
|
+
<dl className="space-y-1">
|
|
170
|
+
{rows.map((row) => (
|
|
171
|
+
<div
|
|
172
|
+
key={row.label}
|
|
173
|
+
className="flex items-baseline justify-between gap-3 text-[11px]"
|
|
174
|
+
>
|
|
175
|
+
<dt className="shrink-0 text-zinc-400">{row.label}</dt>
|
|
176
|
+
<dd className="truncate text-right font-mono text-zinc-700 dark:text-zinc-300">
|
|
177
|
+
{row.value}
|
|
178
|
+
</dd>
|
|
179
|
+
</div>
|
|
180
|
+
))}
|
|
181
|
+
</dl>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlobalDropzone - 全屏拖拽导入区 + MIME 类型校验(W9.3)
|
|
3
|
+
*
|
|
4
|
+
* 当用户拖拽文件进入窗口时,显示全屏遮罩引导用户拖到任意位置即可导入。
|
|
5
|
+
* 通过 `accept` 白名单做 MIME 类型校验,拒绝类型时给出视觉反馈 + 警告。
|
|
6
|
+
*
|
|
7
|
+
* 工作机制:
|
|
8
|
+
* 1. 监听 window dragenter / dragover / dragleave / drop 事件
|
|
9
|
+
* 2. dragenter 显示遮罩,dragleave(离开 window)隐藏
|
|
10
|
+
* 3. drop 时校验文件类型 → 合法则 importFiles,非法则提示拒绝原因
|
|
11
|
+
*
|
|
12
|
+
* MIME 校验:
|
|
13
|
+
* - 默认接受所有 image/* 类型(以及 video/audio/pdf 等,与 workspace 多媒体定位对齐)
|
|
14
|
+
* - 拒绝类型在遮罩中央显示红色提示,并阻止导入
|
|
15
|
+
*
|
|
16
|
+
* 与 AssetPanel / Canvas 内置的局部 dropzone 互不冲突:它们各自处理自己的
|
|
17
|
+
* drop 事件并 `stopPropagation()`,本组件的 window drop 监听不会触发。
|
|
18
|
+
*
|
|
19
|
+
* 防御性兜底:即便子组件忘了 `stopPropagation`,本组件也会再次处理 drop 事件,
|
|
20
|
+
* 但通过 `importFiles` 的幂等性(以 File 引用去重)— 不,File 引用并不去重,
|
|
21
|
+
* 所以子组件**必须** `stopPropagation()`。本组件在 drop 事件触发时,先检查
|
|
22
|
+
* `e.defaultPrevented`(子组件会 `preventDefault()` 表明已处理),若已处理
|
|
23
|
+
* 则直接 return,避免双重导入。
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <GlobalDropzone accept="image/*" />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as React from 'react';
|
|
32
|
+
import { Icon } from '@lokvis/ui-core';
|
|
33
|
+
import { useWorkspaceStore } from '../store/index.js';
|
|
34
|
+
|
|
35
|
+
export interface GlobalDropzoneProps {
|
|
36
|
+
/** 接受的 MIME 类型(逗号分隔),默认接受图片/视频/音频/PDF */
|
|
37
|
+
accept?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 默认接受的类型:与 Workspace 多媒体定位对齐 */
|
|
42
|
+
const DEFAULT_ACCEPT = 'image/*,video/*,audio/*,application/pdf';
|
|
43
|
+
|
|
44
|
+
/** 检查单个文件是否符合 accept 白名单 */
|
|
45
|
+
function isFileAccepted(file: File, accept: string): boolean {
|
|
46
|
+
if (!accept) return true;
|
|
47
|
+
const patterns = accept.split(',').map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
48
|
+
if (patterns.length === 0) return true;
|
|
49
|
+
const mime = file.type.toLowerCase();
|
|
50
|
+
const name = file.name.toLowerCase();
|
|
51
|
+
return patterns.some((p) => {
|
|
52
|
+
// image/* → image/(prefix match)
|
|
53
|
+
if (p.endsWith('/*')) {
|
|
54
|
+
return mime.startsWith(p.slice(0, -1));
|
|
55
|
+
}
|
|
56
|
+
// image/jpeg → exact mime match
|
|
57
|
+
if (p.includes('/')) {
|
|
58
|
+
return mime === p;
|
|
59
|
+
}
|
|
60
|
+
// .png → extension match
|
|
61
|
+
if (p.startsWith('.')) {
|
|
62
|
+
return name.endsWith(p);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function GlobalDropzone({
|
|
69
|
+
accept = DEFAULT_ACCEPT,
|
|
70
|
+
className = '',
|
|
71
|
+
}: GlobalDropzoneProps) {
|
|
72
|
+
const importFiles = useWorkspaceStore((s) => s.importFiles);
|
|
73
|
+
const setStatus = useWorkspaceStore((s) => s.setStatus);
|
|
74
|
+
const setError = useWorkspaceStore((s) => s.setError);
|
|
75
|
+
|
|
76
|
+
// dragCounter:用计数器而非布尔,避免子元素 dragenter/dragleave 触发抖动
|
|
77
|
+
const [dragCounter, setDragCounter] = React.useState(0);
|
|
78
|
+
const [rejectedFiles, setRejectedFiles] = React.useState<string[]>([]);
|
|
79
|
+
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
// 阻止浏览器默认行为(打开文件 / 下载),否则 drop 不会触发
|
|
82
|
+
const preventDefault = (e: DragEvent) => {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
};
|
|
86
|
+
// dragenter:进入窗口,计数 +1
|
|
87
|
+
const onDragEnter = (e: DragEvent) => {
|
|
88
|
+
preventDefault(e);
|
|
89
|
+
if (e.dataTransfer?.types?.includes('Files')) {
|
|
90
|
+
setDragCounter((c) => c + 1);
|
|
91
|
+
setRejectedFiles([]);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// dragover:必须 preventDefault 才能触发 drop
|
|
95
|
+
const onDragOver = (e: DragEvent) => {
|
|
96
|
+
preventDefault(e);
|
|
97
|
+
if (e.dataTransfer) {
|
|
98
|
+
// 显示 drop 效果
|
|
99
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
// dragleave:离开窗口,计数 -1
|
|
103
|
+
const onDragLeave = (e: DragEvent) => {
|
|
104
|
+
preventDefault(e);
|
|
105
|
+
// 只在离开 window 时(relatedTarget 为 null 或 document.documentElement)减计数
|
|
106
|
+
// relatedTarget 为 null 表示离开文档边界
|
|
107
|
+
if (e.relatedTarget === null) {
|
|
108
|
+
setDragCounter(0);
|
|
109
|
+
setRejectedFiles([]);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
// drop:校验 + 导入
|
|
113
|
+
const onDrop = (e: DragEvent) => {
|
|
114
|
+
// 兜底:子组件(Canvas / AssetPanel)若已处理 drop 并 preventDefault(),
|
|
115
|
+
// 表明文件已导入,本组件跳过避免双重导入。子组件仍需 stopPropagation()
|
|
116
|
+
// 防止事件冒泡到 window,这里 defaultPrevented 是双保险。
|
|
117
|
+
if (e.defaultPrevented) {
|
|
118
|
+
setDragCounter(0);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
preventDefault(e);
|
|
122
|
+
setDragCounter(0);
|
|
123
|
+
const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
|
124
|
+
if (files.length === 0) return;
|
|
125
|
+
|
|
126
|
+
const accepted: File[] = [];
|
|
127
|
+
const rejected: string[] = [];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (isFileAccepted(file, accept)) {
|
|
130
|
+
accepted.push(file);
|
|
131
|
+
} else {
|
|
132
|
+
rejected.push(`${file.name} (${file.type || 'unknown'})`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (rejected.length > 0) {
|
|
136
|
+
setRejectedFiles(rejected);
|
|
137
|
+
// 5 秒后自动清空拒绝提示
|
|
138
|
+
setTimeout(() => setRejectedFiles([]), 5000);
|
|
139
|
+
}
|
|
140
|
+
if (accepted.length > 0) {
|
|
141
|
+
void importFiles(accepted);
|
|
142
|
+
} else {
|
|
143
|
+
setStatus(`Rejected ${rejected.length} file(s) — unsupported type`);
|
|
144
|
+
setError(null);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
window.addEventListener('dragenter', onDragEnter);
|
|
149
|
+
window.addEventListener('dragover', onDragOver);
|
|
150
|
+
window.addEventListener('dragleave', onDragLeave);
|
|
151
|
+
window.addEventListener('drop', onDrop);
|
|
152
|
+
return () => {
|
|
153
|
+
window.removeEventListener('dragenter', onDragEnter);
|
|
154
|
+
window.removeEventListener('dragover', onDragOver);
|
|
155
|
+
window.removeEventListener('dragleave', onDragLeave);
|
|
156
|
+
window.removeEventListener('drop', onDrop);
|
|
157
|
+
};
|
|
158
|
+
}, [accept, importFiles, setStatus, setError]);
|
|
159
|
+
|
|
160
|
+
const isDragging = dragCounter > 0;
|
|
161
|
+
const hasRejections = rejectedFiles.length > 0;
|
|
162
|
+
|
|
163
|
+
if (!isDragging && !hasRejections) return null;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
className={`pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-8 ${className}`}
|
|
168
|
+
role="alertdialog"
|
|
169
|
+
aria-live="polite"
|
|
170
|
+
>
|
|
171
|
+
{/* 遮罩 */}
|
|
172
|
+
<div className="absolute inset-0 bg-indigo-50/80 backdrop-blur-sm dark:bg-indigo-950/60" />
|
|
173
|
+
|
|
174
|
+
{/* 内容 */}
|
|
175
|
+
<div
|
|
176
|
+
className={`relative w-full max-w-md rounded-2xl border-2 border-dashed p-8 text-center bg-white shadow-2xl dark:bg-zinc-900 ${
|
|
177
|
+
hasRejections ? 'border-red-400 dark:border-red-600' : 'border-indigo-500 dark:border-indigo-400'
|
|
178
|
+
}`}
|
|
179
|
+
>
|
|
180
|
+
{hasRejections ? (
|
|
181
|
+
<>
|
|
182
|
+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
|
|
183
|
+
<Icon size={24} className="text-red-500" strokeWidth={2}>
|
|
184
|
+
<circle cx="12" cy="12" r="10" />
|
|
185
|
+
<line x1="15" y1="9" x2="9" y2="15" />
|
|
186
|
+
<line x1="9" y1="9" x2="15" y2="15" />
|
|
187
|
+
</Icon>
|
|
188
|
+
</div>
|
|
189
|
+
<p className="mt-3 text-sm font-semibold text-red-700 dark:text-red-400">
|
|
190
|
+
Unsupported file type
|
|
191
|
+
</p>
|
|
192
|
+
<p className="mt-1.5 text-xs text-red-600/80 dark:text-red-400/70">
|
|
193
|
+
{rejectedFiles.join(', ')}
|
|
194
|
+
</p>
|
|
195
|
+
<p className="mt-2 text-[10px] text-zinc-500">
|
|
196
|
+
Accepted: {accept || 'all files'}
|
|
197
|
+
</p>
|
|
198
|
+
</>
|
|
199
|
+
) : (
|
|
200
|
+
<>
|
|
201
|
+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900/40">
|
|
202
|
+
<Icon size={24} className="text-indigo-500" strokeWidth={1.5}>
|
|
203
|
+
<path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
|
204
|
+
</Icon>
|
|
205
|
+
</div>
|
|
206
|
+
<p className="mt-3 text-sm font-semibold text-indigo-700 dark:text-indigo-300">
|
|
207
|
+
Drop files to import
|
|
208
|
+
</p>
|
|
209
|
+
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
|
210
|
+
Files are processed locally in your browser
|
|
211
|
+
</p>
|
|
212
|
+
<p className="mt-2 text-[10px] text-zinc-400">
|
|
213
|
+
Accepted: {accept || 'all files'}
|
|
214
|
+
</p>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|