@lokvis/ui-react 0.1.0 → 0.2.0

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 (160) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/AssetPanel.d.ts +2 -2
  3. package/dist/components/AssetPanel.d.ts.map +1 -1
  4. package/dist/components/AssetPanel.js +80 -7
  5. package/dist/components/AssetPanel.js.map +1 -1
  6. package/dist/components/Canvas.d.ts +9 -1
  7. package/dist/components/Canvas.d.ts.map +1 -1
  8. package/dist/components/Canvas.js +41 -3
  9. package/dist/components/Canvas.js.map +1 -1
  10. package/dist/components/CommandPalette.d.ts +48 -0
  11. package/dist/components/CommandPalette.d.ts.map +1 -0
  12. package/dist/components/CommandPalette.js +154 -0
  13. package/dist/components/CommandPalette.js.map +1 -0
  14. package/dist/components/CompareSlider.d.ts +25 -0
  15. package/dist/components/CompareSlider.d.ts.map +1 -0
  16. package/dist/components/CompareSlider.js +106 -0
  17. package/dist/components/CompareSlider.js.map +1 -0
  18. package/dist/components/DownloadPanel.d.ts +20 -0
  19. package/dist/components/DownloadPanel.d.ts.map +1 -0
  20. package/dist/components/DownloadPanel.js +169 -0
  21. package/dist/components/DownloadPanel.js.map +1 -0
  22. package/dist/components/ErrorBanner.d.ts +22 -0
  23. package/dist/components/ErrorBanner.d.ts.map +1 -0
  24. package/dist/components/ErrorBanner.js +47 -0
  25. package/dist/components/ErrorBanner.js.map +1 -0
  26. package/dist/components/ExifPanel.d.ts +26 -0
  27. package/dist/components/ExifPanel.d.ts.map +1 -0
  28. package/dist/components/ExifPanel.js +104 -0
  29. package/dist/components/ExifPanel.js.map +1 -0
  30. package/dist/components/GlobalDropzone.d.ts +37 -0
  31. package/dist/components/GlobalDropzone.d.ts.map +1 -0
  32. package/dist/components/GlobalDropzone.js +154 -0
  33. package/dist/components/GlobalDropzone.js.map +1 -0
  34. package/dist/components/HistoryPanel.d.ts +17 -7
  35. package/dist/components/HistoryPanel.d.ts.map +1 -1
  36. package/dist/components/HistoryPanel.js +74 -62
  37. package/dist/components/HistoryPanel.js.map +1 -1
  38. package/dist/components/Inspector.d.ts.map +1 -1
  39. package/dist/components/Inspector.js +3 -1
  40. package/dist/components/Inspector.js.map +1 -1
  41. package/dist/components/PipelineBar.d.ts.map +1 -1
  42. package/dist/components/PipelineBar.js +4 -3
  43. package/dist/components/PipelineBar.js.map +1 -1
  44. package/dist/components/ProgressBar.d.ts +18 -0
  45. package/dist/components/ProgressBar.d.ts.map +1 -0
  46. package/dist/components/ProgressBar.js +35 -0
  47. package/dist/components/ProgressBar.js.map +1 -0
  48. package/dist/components/StatusBar.d.ts +8 -2
  49. package/dist/components/StatusBar.d.ts.map +1 -1
  50. package/dist/components/StatusBar.js +59 -4
  51. package/dist/components/StatusBar.js.map +1 -1
  52. package/dist/components/ThemeToggle.d.ts +20 -0
  53. package/dist/components/ThemeToggle.d.ts.map +1 -0
  54. package/dist/components/ThemeToggle.js +60 -0
  55. package/dist/components/ThemeToggle.js.map +1 -0
  56. package/dist/components/WorkflowEditor.d.ts +24 -0
  57. package/dist/components/WorkflowEditor.d.ts.map +1 -0
  58. package/dist/components/WorkflowEditor.js +160 -0
  59. package/dist/components/WorkflowEditor.js.map +1 -0
  60. package/dist/components/WorkflowTemplates.d.ts +23 -0
  61. package/dist/components/WorkflowTemplates.d.ts.map +1 -0
  62. package/dist/components/WorkflowTemplates.js +32 -0
  63. package/dist/components/WorkflowTemplates.js.map +1 -0
  64. package/dist/components/Workspace.d.ts +48 -2
  65. package/dist/components/Workspace.d.ts.map +1 -1
  66. package/dist/components/Workspace.js +80 -5
  67. package/dist/components/Workspace.js.map +1 -1
  68. package/dist/data/workflow-templates.d.ts +42 -0
  69. package/dist/data/workflow-templates.d.ts.map +1 -0
  70. package/dist/data/workflow-templates.js +112 -0
  71. package/dist/data/workflow-templates.js.map +1 -0
  72. package/dist/hooks/useDebouncedRun.d.ts +49 -0
  73. package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
  74. package/dist/hooks/useDebouncedRun.js +117 -0
  75. package/dist/hooks/useDebouncedRun.js.map +1 -0
  76. package/dist/hooks/useLokvis.js +1 -1
  77. package/dist/hooks/useMediaQuery.d.ts +27 -0
  78. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  79. package/dist/hooks/useMediaQuery.js +41 -0
  80. package/dist/hooks/useMediaQuery.js.map +1 -0
  81. package/dist/hooks/useShareLink.d.ts +58 -0
  82. package/dist/hooks/useShareLink.d.ts.map +1 -0
  83. package/dist/hooks/useShareLink.js +120 -0
  84. package/dist/hooks/useShareLink.js.map +1 -0
  85. package/dist/hooks/useTheme.d.ts +30 -0
  86. package/dist/hooks/useTheme.d.ts.map +1 -0
  87. package/dist/hooks/useTheme.js +105 -0
  88. package/dist/hooks/useTheme.js.map +1 -0
  89. package/dist/hooks/useWorkflowProgress.d.ts +28 -0
  90. package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
  91. package/dist/hooks/useWorkflowProgress.js +25 -0
  92. package/dist/hooks/useWorkflowProgress.js.map +1 -0
  93. package/dist/hooks/useWorkflows.d.ts +86 -0
  94. package/dist/hooks/useWorkflows.d.ts.map +1 -0
  95. package/dist/hooks/useWorkflows.js +247 -0
  96. package/dist/hooks/useWorkflows.js.map +1 -0
  97. package/dist/index.d.ts +26 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +26 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/store/assets-slice.d.ts.map +1 -1
  102. package/dist/store/assets-slice.js +19 -0
  103. package/dist/store/assets-slice.js.map +1 -1
  104. package/dist/store/history-slice.d.ts +18 -0
  105. package/dist/store/history-slice.d.ts.map +1 -0
  106. package/dist/store/history-slice.js +42 -0
  107. package/dist/store/history-slice.js.map +1 -0
  108. package/dist/store/index.d.ts +1 -0
  109. package/dist/store/index.d.ts.map +1 -1
  110. package/dist/store/index.js +3 -0
  111. package/dist/store/index.js.map +1 -1
  112. package/dist/store/runtime-slice.d.ts +10 -2
  113. package/dist/store/runtime-slice.d.ts.map +1 -1
  114. package/dist/store/runtime-slice.js +77 -28
  115. package/dist/store/runtime-slice.js.map +1 -1
  116. package/dist/store/types.d.ts +70 -1
  117. package/dist/store/types.d.ts.map +1 -1
  118. package/dist/store/types.js +10 -0
  119. package/dist/store/types.js.map +1 -1
  120. package/dist/store/workflow-slice.d.ts +1 -1
  121. package/dist/store/workflow-slice.d.ts.map +1 -1
  122. package/dist/store/workflow-slice.js +167 -20
  123. package/dist/store/workflow-slice.js.map +1 -1
  124. package/dist/store.d.ts +76 -0
  125. package/dist/store.d.ts.map +1 -0
  126. package/dist/store.js +217 -0
  127. package/dist/store.js.map +1 -0
  128. package/package.json +9 -6
  129. package/src/components/AssetPanel.tsx +154 -12
  130. package/src/components/Canvas.tsx +85 -7
  131. package/src/components/CommandPalette.tsx +300 -0
  132. package/src/components/CompareSlider.tsx +186 -0
  133. package/src/components/DownloadPanel.tsx +252 -0
  134. package/src/components/ErrorBanner.tsx +95 -0
  135. package/src/components/ExifPanel.tsx +187 -0
  136. package/src/components/GlobalDropzone.tsx +220 -0
  137. package/src/components/HistoryPanel.tsx +231 -104
  138. package/src/components/Inspector.tsx +7 -2
  139. package/src/components/PipelineBar.tsx +13 -12
  140. package/src/components/ProgressBar.tsx +104 -0
  141. package/src/components/StatusBar.tsx +124 -5
  142. package/src/components/ThemeToggle.tsx +122 -0
  143. package/src/components/WorkflowEditor.tsx +382 -0
  144. package/src/components/WorkflowTemplates.tsx +101 -0
  145. package/src/components/Workspace.tsx +207 -9
  146. package/src/data/workflow-templates.ts +137 -0
  147. package/src/hooks/useDebouncedRun.ts +151 -0
  148. package/src/hooks/useLokvis.ts +1 -1
  149. package/src/hooks/useMediaQuery.ts +53 -0
  150. package/src/hooks/useShareLink.ts +158 -0
  151. package/src/hooks/useTheme.ts +116 -0
  152. package/src/hooks/useWorkflowProgress.ts +41 -0
  153. package/src/hooks/useWorkflows.ts +326 -0
  154. package/src/index.ts +32 -0
  155. package/src/store/assets-slice.ts +19 -0
  156. package/src/store/history-slice.ts +65 -0
  157. package/src/store/index.ts +3 -0
  158. package/src/store/runtime-slice.ts +89 -32
  159. package/src/store/types.ts +71 -1
  160. package/src/store/workflow-slice.ts +178 -20
@@ -0,0 +1,252 @@
1
+ /**
2
+ * DownloadPanel - 工作流输出下载面板(W9.5)
3
+ *
4
+ * 展示上次工作流执行的输出资产,提供:
5
+ * - 单项下载(从 runtime.exportAsset 取 blob,触发浏览器下载)
6
+ * - 全部下载(逐项触发,BATCH_DOWNLOAD_DELAY_MS 间隔,避免浏览器拦截;长期方案见该常量注释)
7
+ * - 清空输出列表
8
+ *
9
+ * 与 apps/playground 的 DownloadManager(独立工具页)不同:本组件是 Workspace
10
+ * 内嵌的轻量面板,数据来自 store.lastOutputIds(由 workflow-slice.run 写入)。
11
+ *
12
+ * 下载命名:`lokvis-output-{assetId前8位}.{ext}`,简洁且避免覆盖。
13
+ * 不引入 JSZip(需新增依赖),用浏览器原生 downloadBlob 实现。
14
+ */
15
+
16
+ import * as React from 'react';
17
+ import { Icon } from '@lokvis/ui-core';
18
+ import { useWorkspaceStore } from '../store/index.js';
19
+
20
+ export interface DownloadPanelProps {
21
+ className?: string;
22
+ }
23
+
24
+ /** 格式化字节数 */
25
+ function formatBytes(bytes: number): string {
26
+ if (bytes < 1024) return `${bytes} B`;
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
29
+ }
30
+
31
+ /**
32
+ * 批量下载时每项之间的延迟毫秒数。
33
+ *
34
+ * 短期方案:浏览器(尤其 Chrome)会拦截同一 tick 内触发的多个 programmatic
35
+ * `<a download>` 点击,把第一个之后的视为"非用户手势"弹窗拦截。两次下载之间
36
+ * 插入小延迟可绕过该启发式判定。该值是经验值,不是规范要求 —— 不同浏览器
37
+ * 版本行为可能变化。
38
+ *
39
+ * 长期方案:用 JSZip 将多个输出打包成单个 ZIP 下载(只需一次用户手势)。
40
+ * 本组件暂未引入 JSZip 依赖(见文件头注释),待 M2 引入 archive capability 后
41
+ * 一并迁移,届时可移除此常量与 sleep 调用。
42
+ */
43
+ const BATCH_DOWNLOAD_DELAY_MS = 200;
44
+
45
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
46
+
47
+ /** 从扩展名或 mime 推断下载扩展名 */
48
+ function extFromMime(mime: string): string {
49
+ const sub = mime.split('/')[1] ?? 'bin';
50
+ // image/svg+xml → svg+xml → svg(取 + 前部分)
51
+ return sub.split('+')[0] ?? sub;
52
+ }
53
+
54
+ /** 触发浏览器下载 */
55
+ function downloadBlob(blob: Blob, filename: string): void {
56
+ const url = URL.createObjectURL(blob);
57
+ const a = document.createElement('a');
58
+ a.href = url;
59
+ a.download = filename;
60
+ document.body.appendChild(a);
61
+ a.click();
62
+ document.body.removeChild(a);
63
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
64
+ }
65
+
66
+ export function DownloadPanel({ className = '' }: DownloadPanelProps) {
67
+ const assets = useWorkspaceStore((s) => s.assets);
68
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
69
+ const runtime = useWorkspaceStore((s) => s.runtime);
70
+ const clearOutputs = useWorkspaceStore((s) => s.clearOutputs);
71
+ const setStatus = useWorkspaceStore((s) => s.setStatus);
72
+
73
+ const [downloaded, setDownloaded] = React.useState<Set<string>>(new Set());
74
+ const [batchDownloading, setBatchDownloading] = React.useState(false);
75
+ const [error, setError] = React.useState<string | null>(null);
76
+
77
+ // 卸载标志:批量下载是异步循环,卸载后不应再 setState
78
+ const mountedRef = React.useRef(true);
79
+ React.useEffect(() => {
80
+ mountedRef.current = true;
81
+ return () => {
82
+ mountedRef.current = false;
83
+ };
84
+ }, []);
85
+
86
+ // 输出资产列表(从 store.assets 中按 lastOutputIds 顺序取)
87
+ const outputAssets = React.useMemo(() => {
88
+ return lastOutputIds
89
+ .map((id) => assets.find((a) => a.id === id))
90
+ .filter((a): a is NonNullable<typeof a> => a !== undefined);
91
+ }, [lastOutputIds, assets]);
92
+
93
+ // outputs 变化时清空 downloaded 状态
94
+ React.useEffect(() => {
95
+ setDownloaded(new Set());
96
+ setError(null);
97
+ }, [lastOutputIds]);
98
+
99
+ const totalSize = outputAssets.reduce((sum, a) => sum + a.metadata.size, 0);
100
+ const downloadedCount = outputAssets.filter((a) => downloaded.has(a.id)).length;
101
+ const allDownloaded = outputAssets.length > 0 && downloadedCount === outputAssets.length;
102
+
103
+ async function handleDownloadOne(id: string) {
104
+ if (!runtime) return;
105
+ const asset = outputAssets.find((a) => a.id === id);
106
+ if (!asset) return;
107
+ try {
108
+ const blob = await runtime.exportAsset(id);
109
+ const ext = extFromMime(asset.metadata.mimeType);
110
+ const filename = `lokvis-output-${asset.id.slice(0, 8)}.${ext}`;
111
+ downloadBlob(blob, filename);
112
+ if (mountedRef.current) {
113
+ setDownloaded((prev) => new Set(prev).add(id));
114
+ }
115
+ } catch (err) {
116
+ if (mountedRef.current) {
117
+ setError(err instanceof Error ? err.message : String(err));
118
+ }
119
+ }
120
+ }
121
+
122
+ async function handleDownloadAll() {
123
+ if (!runtime || batchDownloading) return;
124
+ setBatchDownloading(true);
125
+ setError(null);
126
+ setStatus('Downloading outputs...');
127
+ // 用本地 Set 跟踪本次循环已下载 id,避免 downloaded 闭包快照与 state 不同步
128
+ // (review 反馈:循环中 setDownloaded 更新 state 但闭包 downloaded 不变,
129
+ // 虽有 batchDownloading guard 阻止并发,逻辑上仍应读取最新已下载集合)
130
+ const localDownloaded = new Set(downloaded);
131
+ try {
132
+ for (const asset of outputAssets) {
133
+ // 卸载后立即停止循环(避免对已卸载组件 setState)
134
+ if (!mountedRef.current) return;
135
+ if (localDownloaded.has(asset.id)) continue;
136
+ try {
137
+ const blob = await runtime.exportAsset(asset.id);
138
+ const ext = extFromMime(asset.metadata.mimeType);
139
+ const filename = `lokvis-output-${asset.id.slice(0, 8)}.${ext}`;
140
+ downloadBlob(blob, filename);
141
+ localDownloaded.add(asset.id);
142
+ if (mountedRef.current) {
143
+ setDownloaded((prev) => new Set(prev).add(asset.id));
144
+ }
145
+ await sleep(BATCH_DOWNLOAD_DELAY_MS);
146
+ } catch (err) {
147
+ if (mountedRef.current) {
148
+ setError(err instanceof Error ? err.message : String(err));
149
+ }
150
+ // 单项失败不阻断后续
151
+ }
152
+ }
153
+ if (mountedRef.current) {
154
+ setStatus('Downloads complete');
155
+ }
156
+ } finally {
157
+ if (mountedRef.current) {
158
+ setBatchDownloading(false);
159
+ }
160
+ }
161
+ }
162
+
163
+ function handleClear() {
164
+ clearOutputs();
165
+ setDownloaded(new Set());
166
+ }
167
+
168
+ if (outputAssets.length === 0) return null;
169
+
170
+ return (
171
+ <div
172
+ className={`flex flex-col border-t border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
173
+ aria-label="Outputs panel"
174
+ >
175
+ <div className="flex items-center justify-between px-3 h-9 shrink-0 border-b border-zinc-200 dark:border-zinc-800">
176
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
177
+ Outputs
178
+ </span>
179
+ <span className="text-[11px] tabular-nums text-zinc-400">
180
+ {downloadedCount}/{outputAssets.length} · {formatBytes(totalSize)}
181
+ </span>
182
+ </div>
183
+
184
+ <div className="flex items-center gap-2 px-3 py-2 overflow-x-auto">
185
+ {outputAssets.map((asset) => {
186
+ const isDownloaded = downloaded.has(asset.id);
187
+ const dims = asset.metadata.dimensions;
188
+ return (
189
+ <div
190
+ key={asset.id}
191
+ className="flex shrink-0 flex-col gap-1 rounded-md border border-zinc-200 p-2 dark:border-zinc-700"
192
+ >
193
+ <div className="flex items-center gap-2">
194
+ <span className="rounded bg-zinc-100 px-1 py-0.5 text-[9px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
195
+ {asset.metadata.format.toUpperCase()}
196
+ </span>
197
+ {dims && (
198
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 tabular-nums">
199
+ {dims.width}×{dims.height}
200
+ </span>
201
+ )}
202
+ <span className="text-[10px] text-zinc-400 tabular-nums">
203
+ {formatBytes(asset.metadata.size)}
204
+ </span>
205
+ </div>
206
+ <div className="flex items-center gap-1">
207
+ <button
208
+ type="button"
209
+ onClick={() => void handleDownloadOne(asset.id)}
210
+ className={`flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
211
+ isDownloaded
212
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
213
+ : 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:hover:bg-indigo-900/40'
214
+ }`}
215
+ >
216
+ <Icon size={10} strokeWidth={2}>
217
+ <path d="M12 3v12m0 0l-4-4m4 4l4-4M3 17v2a2 2 0 002 2h14a2 2 0 002-2v-2" />
218
+ </Icon>
219
+ {isDownloaded ? 'Downloaded' : 'Download'}
220
+ </button>
221
+ </div>
222
+ </div>
223
+ );
224
+ })}
225
+
226
+ <div className="flex shrink-0 items-center gap-1">
227
+ <button
228
+ type="button"
229
+ onClick={() => void handleDownloadAll()}
230
+ disabled={batchDownloading || allDownloaded}
231
+ className="rounded-md bg-indigo-600 px-2.5 py-1 text-[10px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
232
+ >
233
+ {batchDownloading ? 'Downloading...' : 'Download All'}
234
+ </button>
235
+ <button
236
+ type="button"
237
+ onClick={handleClear}
238
+ className="rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
239
+ >
240
+ Clear
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ {error && (
246
+ <p className="px-3 pb-2 text-[10px] text-red-500" role="alert">
247
+ {error}
248
+ </p>
249
+ )}
250
+ </div>
251
+ );
252
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ErrorBanner - 错误信息横幅(W11.3)
3
+ *
4
+ * 当 store.error 非空时显示,展示错误信息并提供:
5
+ * - 关闭按钮(清除 error)
6
+ * - 重试按钮(重新执行工作流)
7
+ *
8
+ * 节点失败高亮由 WorkflowEditor 的节点状态颜色处理(已实现);
9
+ * 本组件负责全局错误信息的可见性。
10
+ *
11
+ * 显式错误计数器作为可见性派生依据:
12
+ * store.error 是字符串,相同错误多次设置时引用不变,useEffect 依赖 [error] 不会触发。
13
+ * 改为依赖 errorSeq(error 出现的序号),每次 setError(非 null)都递增,确保 banner 重新弹出。
14
+ *
15
+ * @module ErrorBanner
16
+ */
17
+
18
+ import * as React from 'react';
19
+ import { Icon } from '@lokvis/ui-core';
20
+ import { useWorkspaceStore } from '../store/index.js';
21
+
22
+ export interface ErrorBannerProps {
23
+ className?: string;
24
+ }
25
+
26
+ export function ErrorBanner({ className = '' }: ErrorBannerProps) {
27
+ const error = useWorkspaceStore((s) => s.error);
28
+ const errorSeq = useWorkspaceStore((s) => s.errorSeq);
29
+ const setError = useWorkspaceStore((s) => s.setError);
30
+ const run = useWorkspaceStore((s) => s.run);
31
+ const nodes = useWorkspaceStore((s) => s.nodes);
32
+ const running = useWorkspaceStore((s) => s.running);
33
+
34
+ // 用户关闭后隐藏;新错误(errorSeq 变化)时重新显示。
35
+ // 用 errorSeq 而非 error 字符串本身:store.run() 在 setError(null) 之后才设新 error,
36
+ // errorSeq 在 setError(非 null) 时递增,即使错误消息相同也能感知到"新的错误事件"。
37
+ const [dismissedSeq, setDismissedSeq] = React.useState<number | null>(null);
38
+
39
+ const visible = error !== null && dismissedSeq !== errorSeq;
40
+
41
+ const handleDismiss = React.useCallback(() => {
42
+ setDismissedSeq(errorSeq);
43
+ setError(null);
44
+ }, [errorSeq, setError]);
45
+
46
+ const handleRetry = React.useCallback(() => {
47
+ // 先清 error 再 run,避免 run 同步抛错时 banner 已隐藏且 errorSeq 未变化
48
+ setError(null);
49
+ void run();
50
+ }, [setError, run]);
51
+
52
+ if (!visible || !error) return null;
53
+
54
+ const canRetry = nodes.length > 0 && !running;
55
+
56
+ return (
57
+ <div
58
+ className={`flex items-start gap-2 border-b border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/20 ${className}`}
59
+ role="alert"
60
+ >
61
+ <Icon size={14} className="mt-0.5 shrink-0 text-red-500">
62
+ <circle cx="12" cy="12" r="10" />
63
+ <line x1="12" y1="8" x2="12" y2="12" />
64
+ <line x1="12" y1="16" x2="12.01" y2="16" />
65
+ </Icon>
66
+ <div className="flex-1 min-w-0">
67
+ <p className="text-[11px] font-medium text-red-700 dark:text-red-400">
68
+ 执行出错
69
+ </p>
70
+ <p className="mt-0.5 break-words text-[10px] text-red-600/80 dark:text-red-400/70">
71
+ {error}
72
+ </p>
73
+ </div>
74
+ <div className="flex shrink-0 items-center gap-1">
75
+ {canRetry && (
76
+ <button
77
+ type="button"
78
+ onClick={handleRetry}
79
+ className="rounded bg-red-100 px-2 py-0.5 text-[10px] font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:hover:bg-red-900/60"
80
+ >
81
+ 重试
82
+ </button>
83
+ )}
84
+ <button
85
+ type="button"
86
+ onClick={handleDismiss}
87
+ aria-label="关闭错误信息"
88
+ className="rounded p-0.5 text-red-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40"
89
+ >
90
+ <Icon size={12} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
91
+ </button>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -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
+ }