@lokvis/ui-react 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +18 -62
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts +4 -4
  5. package/dist/components/Canvas.d.ts.map +1 -1
  6. package/dist/components/Canvas.js +9 -9
  7. package/dist/components/Canvas.js.map +1 -1
  8. package/dist/components/CommandPalette.d.ts +7 -7
  9. package/dist/components/CommandPalette.d.ts.map +1 -1
  10. package/dist/components/CommandPalette.js +10 -10
  11. package/dist/components/CommandPalette.js.map +1 -1
  12. package/dist/components/CompareSlider.d.ts +9 -8
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +10 -9
  15. package/dist/components/CompareSlider.js.map +1 -1
  16. package/dist/components/DownloadPanel.d.ts +3 -3
  17. package/dist/components/DownloadPanel.d.ts.map +1 -1
  18. package/dist/components/DownloadPanel.js +10 -17
  19. package/dist/components/DownloadPanel.js.map +1 -1
  20. package/dist/components/ErrorBanner.d.ts +2 -2
  21. package/dist/components/ErrorBanner.d.ts.map +1 -1
  22. package/dist/components/ErrorBanner.js +3 -3
  23. package/dist/components/ErrorBanner.js.map +1 -1
  24. package/dist/components/ErrorBoundary.d.ts +40 -0
  25. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  26. package/dist/components/ErrorBoundary.js +52 -0
  27. package/dist/components/ErrorBoundary.js.map +1 -0
  28. package/dist/components/ExifPanel.d.ts +1 -1
  29. package/dist/components/ExifPanel.d.ts.map +1 -1
  30. package/dist/components/ExifPanel.js +2 -2
  31. package/dist/components/ExifPanel.js.map +1 -1
  32. package/dist/components/GlobalDropzone.d.ts +5 -5
  33. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  34. package/dist/components/GlobalDropzone.js +15 -8
  35. package/dist/components/GlobalDropzone.js.map +1 -1
  36. package/dist/components/HistoryPanel.d.ts +2 -2
  37. package/dist/components/HistoryPanel.d.ts.map +1 -1
  38. package/dist/components/HistoryPanel.js +23 -23
  39. package/dist/components/HistoryPanel.js.map +1 -1
  40. package/dist/components/Inspector.d.ts.map +1 -1
  41. package/dist/components/Inspector.js +13 -10
  42. package/dist/components/Inspector.js.map +1 -1
  43. package/dist/components/ParamForm.d.ts.map +1 -1
  44. package/dist/components/ParamForm.js +10 -7
  45. package/dist/components/ParamForm.js.map +1 -1
  46. package/dist/components/PipelineBar.d.ts.map +1 -1
  47. package/dist/components/PipelineBar.js +19 -28
  48. package/dist/components/PipelineBar.js.map +1 -1
  49. package/dist/components/ProgressBar.d.ts +4 -4
  50. package/dist/components/ProgressBar.d.ts.map +1 -1
  51. package/dist/components/ProgressBar.js +8 -8
  52. package/dist/components/ProgressBar.js.map +1 -1
  53. package/dist/components/StatusBar.d.ts +3 -3
  54. package/dist/components/StatusBar.d.ts.map +1 -1
  55. package/dist/components/StatusBar.js +12 -21
  56. package/dist/components/StatusBar.js.map +1 -1
  57. package/dist/components/StatusDot.d.ts +12 -0
  58. package/dist/components/StatusDot.d.ts.map +1 -0
  59. package/dist/components/StatusDot.js +15 -0
  60. package/dist/components/StatusDot.js.map +1 -0
  61. package/dist/components/ThemeToggle.d.ts +4 -4
  62. package/dist/components/ThemeToggle.d.ts.map +1 -1
  63. package/dist/components/ThemeToggle.js +7 -7
  64. package/dist/components/ThemeToggle.js.map +1 -1
  65. package/dist/components/ToolRunner.d.ts +53 -0
  66. package/dist/components/ToolRunner.d.ts.map +1 -0
  67. package/dist/components/ToolRunner.js +142 -0
  68. package/dist/components/ToolRunner.js.map +1 -0
  69. package/dist/components/Toolbar.d.ts.map +1 -1
  70. package/dist/components/Toolbar.js +3 -3
  71. package/dist/components/Toolbar.js.map +1 -1
  72. package/dist/components/WorkflowEditor.d.ts +6 -6
  73. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  74. package/dist/components/WorkflowEditor.js +40 -49
  75. package/dist/components/WorkflowEditor.js.map +1 -1
  76. package/dist/components/WorkflowTemplates.d.ts +2 -2
  77. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  78. package/dist/components/WorkflowTemplates.js +17 -7
  79. package/dist/components/WorkflowTemplates.js.map +1 -1
  80. package/dist/components/Workspace.d.ts +45 -23
  81. package/dist/components/Workspace.d.ts.map +1 -1
  82. package/dist/components/Workspace.js +87 -40
  83. package/dist/components/Workspace.js.map +1 -1
  84. package/dist/hooks/useCustomPresets.d.ts +98 -0
  85. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  86. package/dist/hooks/useCustomPresets.js +210 -0
  87. package/dist/hooks/useCustomPresets.js.map +1 -0
  88. package/dist/hooks/useLokvis.d.ts +19 -1
  89. package/dist/hooks/useLokvis.d.ts.map +1 -1
  90. package/dist/hooks/useLokvis.js +30 -9
  91. package/dist/hooks/useLokvis.js.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +6 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/store/assets-slice.d.ts +8 -1
  97. package/dist/store/assets-slice.d.ts.map +1 -1
  98. package/dist/store/assets-slice.js +101 -54
  99. package/dist/store/assets-slice.js.map +1 -1
  100. package/dist/store/capabilities-slice.d.ts +1 -1
  101. package/dist/store/capabilities-slice.d.ts.map +1 -1
  102. package/dist/store/capabilities-slice.js +8 -1
  103. package/dist/store/capabilities-slice.js.map +1 -1
  104. package/dist/store/index.d.ts +1 -1
  105. package/dist/store/index.d.ts.map +1 -1
  106. package/dist/store/index.js +1 -1
  107. package/dist/store/index.js.map +1 -1
  108. package/dist/store/runtime-slice.d.ts.map +1 -1
  109. package/dist/store/runtime-slice.js +22 -16
  110. package/dist/store/runtime-slice.js.map +1 -1
  111. package/dist/store/subscribe-utils.d.ts +45 -0
  112. package/dist/store/subscribe-utils.d.ts.map +1 -0
  113. package/dist/store/subscribe-utils.js +69 -0
  114. package/dist/store/subscribe-utils.js.map +1 -0
  115. package/dist/store/types.d.ts +19 -11
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/types.js +0 -10
  118. package/dist/store/types.js.map +1 -1
  119. package/dist/store/workflow-slice.d.ts +1 -1
  120. package/dist/store/workflow-slice.d.ts.map +1 -1
  121. package/dist/store/workflow-slice.js +19 -51
  122. package/dist/store/workflow-slice.js.map +1 -1
  123. package/dist/utils.d.ts +24 -0
  124. package/dist/utils.d.ts.map +1 -0
  125. package/dist/utils.js +31 -0
  126. package/dist/utils.js.map +1 -0
  127. package/package.json +7 -6
  128. package/src/components/AssetPanel.tsx +224 -264
  129. package/src/components/Canvas.tsx +178 -174
  130. package/src/components/CommandPalette.tsx +242 -242
  131. package/src/components/CompareSlider.tsx +168 -165
  132. package/src/components/DownloadPanel.tsx +201 -207
  133. package/src/components/ErrorBanner.tsx +64 -64
  134. package/src/components/ErrorBoundary.tsx +101 -0
  135. package/src/components/ExifPanel.tsx +154 -154
  136. package/src/components/GlobalDropzone.tsx +175 -170
  137. package/src/components/HistoryPanel.tsx +219 -219
  138. package/src/components/Inspector.tsx +145 -137
  139. package/src/components/ParamForm.tsx +140 -137
  140. package/src/components/PipelineBar.tsx +97 -104
  141. package/src/components/ProgressBar.tsx +78 -78
  142. package/src/components/StatusBar.tsx +145 -152
  143. package/src/components/StatusDot.tsx +27 -0
  144. package/src/components/ThemeToggle.tsx +98 -98
  145. package/src/components/ToolRunner.tsx +352 -0
  146. package/src/components/Toolbar.tsx +65 -65
  147. package/src/components/WorkflowEditor.tsx +333 -326
  148. package/src/components/WorkflowTemplates.tsx +101 -78
  149. package/src/components/Workspace.tsx +310 -223
  150. package/src/css-modules.d.ts +3 -0
  151. package/src/hooks/useCustomPresets.ts +284 -0
  152. package/src/hooks/useLokvis.ts +43 -10
  153. package/src/index.ts +7 -0
  154. package/src/store/assets-slice.ts +106 -52
  155. package/src/store/capabilities-slice.ts +10 -2
  156. package/src/store/index.ts +1 -1
  157. package/src/store/runtime-slice.ts +20 -15
  158. package/src/store/subscribe-utils.ts +82 -0
  159. package/src/store/types.ts +19 -12
  160. package/src/store/workflow-slice.ts +19 -53
  161. package/src/utils.ts +41 -0
  162. package/dist/store.d.ts +0 -76
  163. package/dist/store.d.ts.map +0 -1
  164. package/dist/store.js +0 -217
  165. package/dist/store.js.map +0 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ErrorBoundary - React 错误边界(D6)
3
+ *
4
+ * 捕获子组件树渲染期间的运行时异常,避免整个 Workspace 白屏。
5
+ * 必须是 class 组件(React 错误边界 API 仅支持 class)。
6
+ *
7
+ * 设计:
8
+ * - 默认 fallback 使用 lokvis token,与 Workspace 错误态视觉一致
9
+ * - 可选 fallback 渲染 prop(消费方自定义 UI)
10
+ * - 可选 onError 回调(上报等);默认 console.error,不引入 sentry 等
11
+ * app 层依赖,保持 ui-react 包架构纯净(不反向依赖消费方)
12
+ * - reset() 清空 error 状态,尝试重新渲染子树
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <ErrorBoundary>
17
+ * <Workspace plugins={[...]} />
18
+ * </ErrorBoundary>
19
+ * ```
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ export interface ErrorBoundaryProps {
25
+ children: React.ReactNode;
26
+ /** 自定义 fallback;不传则用默认 UI */
27
+ fallback?: (error: Error, reset: () => void) => React.ReactNode;
28
+ /** 错误回调(上报等),不传则 console.error */
29
+ onError?: (error: Error, info: React.ErrorInfo) => void;
30
+ }
31
+
32
+ interface State {
33
+ error: Error | null;
34
+ }
35
+
36
+ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, State> {
37
+ state: State = { error: null };
38
+
39
+ static getDerivedStateFromError(error: Error): State {
40
+ return { error };
41
+ }
42
+
43
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
44
+ if (this.props.onError) {
45
+ this.props.onError(error, info);
46
+ } else {
47
+ console.error('[lokvis ErrorBoundary]', error, info.componentStack);
48
+ }
49
+ }
50
+
51
+ reset = () => {
52
+ this.setState({ error: null });
53
+ };
54
+
55
+ render() {
56
+ if (this.state.error) {
57
+ if (this.props.fallback) {
58
+ return this.props.fallback(this.state.error, this.reset);
59
+ }
60
+ return <DefaultFallback error={this.state.error} onReset={this.reset} />;
61
+ }
62
+ return this.props.children;
63
+ }
64
+ }
65
+
66
+ function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
67
+ return (
68
+ <div className="flex h-full w-full items-center justify-center p-6" role="alert">
69
+ <div className="max-w-md rounded-xl border border-[var(--lokvis-danger)]/30 bg-[var(--lokvis-surface)] p-6 text-center shadow-[var(--lokvis-elevation-2)]">
70
+ <div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--lokvis-danger)]/15">
71
+ <svg
72
+ width="20"
73
+ height="20"
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ strokeWidth="2"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ className="text-[var(--lokvis-danger)]"
81
+ >
82
+ <circle cx="12" cy="12" r="10" />
83
+ <line x1="12" y1="8" x2="12" y2="12" />
84
+ <line x1="12" y1="16" x2="12.01" y2="16" />
85
+ </svg>
86
+ </div>
87
+ <p className="font-semibold text-[var(--lokvis-fg)]">Something went wrong</p>
88
+ <p className="mt-1.5 break-words text-sm text-[var(--lokvis-fg-muted)]">
89
+ {error.message || 'An unexpected error occurred while rendering this panel.'}
90
+ </p>
91
+ <button
92
+ type="button"
93
+ onClick={onReset}
94
+ className="mt-4 inline-flex items-center justify-center rounded-lg bg-[var(--lokvis-primary)] px-4 py-2 text-sm font-medium text-[var(--lokvis-primary-fg)] transition-colors hover:bg-[var(--lokvis-primary-hover)]"
95
+ >
96
+ Try again
97
+ </button>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -14,7 +14,7 @@
14
14
  * - 仅 image 类型 asset 显示;非 image 隐藏整个面板
15
15
  * - 缓存:按 assetId 缓存(LRU 上限 16),切换 asset 不重复解析
16
16
  * - effect 依赖 selectedAssetId(而非 asset 对象引用),避免 assets 数组
17
- * 重渲染时触发重复 effect
17
+ * 重渲染时触发重复 effect
18
18
  * - 取消:effect 重跑/卸载时不更新 state(避免 stale update)
19
19
  * - 加载/空态/错误三态明确
20
20
  */
@@ -25,163 +25,163 @@ import { formatExifRows, type ExifData } from '@lokvis/schema';
25
25
  import { useWorkspaceStore } from '../store/index.js';
26
26
 
27
27
  export interface ExifPanelProps {
28
- className?: string;
28
+ className?: string;
29
29
  }
30
30
 
31
31
  /** cache LRU 上限:防止长会话切换大量图片时 Map 无限增长 */
32
32
  const EXIF_CACHE_LIMIT = 16;
33
33
 
34
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
- );
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-[var(--lokvis-border)] ${className}`}
120
+ >
121
+ <button
122
+ type="button"
123
+ onClick={() => setOpen(!open)}
124
+ className="flex w-full items-center justify-between px-3 h-[var(--lokvis-panel-header-h)] text-left transition-colors hover:bg-[var(--lokvis-surface)]"
125
+ aria-expanded={open}
126
+ >
127
+ <div className="flex items-center gap-2">
128
+ <Icon
129
+ size={12}
130
+ className={`text-[var(--lokvis-fg-subtle)] 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-[var(--lokvis-fg-subtle)]">
135
+ EXIF
136
+ </span>
137
+ </div>
138
+ {loading ? (
139
+ <span className="text-[10px] text-[var(--lokvis-fg-subtle)]">loading…</span>
140
+ ) : exif ? (
141
+ <span className="font-mono text-[10px] text-[var(--lokvis-success)] bg-[var(--lokvis-success)]/10 px-1.5 py-0.5 rounded">
142
+ {rows.length} fields
143
+ </span>
144
+ ) : error ? (
145
+ <span className="text-[10px] text-[var(--lokvis-danger)]">error</span>
146
+ ) : (
147
+ <span className="text-[10px] text-[var(--lokvis-fg-subtle)]">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-[var(--lokvis-fg-subtle)]">
155
+ Reading EXIF…
156
+ </div>
157
+ ) : error ? (
158
+ <div className="py-3 text-center">
159
+ <p className="text-[11px] text-[var(--lokvis-danger)]">{error}</p>
160
+ </div>
161
+ ) : rows.length === 0 ? (
162
+ <div className="py-4 text-center">
163
+ <p className="text-[11px] text-[var(--lokvis-fg-subtle)]">No EXIF data</p>
164
+ <p className="mt-1 text-[10px] text-[var(--lokvis-fg-muted)]">
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-[var(--lokvis-fg-subtle)]">{row.label}</dt>
176
+ <dd className="truncate text-right font-mono text-[var(--lokvis-fg-muted)]">
177
+ {row.value}
178
+ </dd>
179
+ </div>
180
+ ))}
181
+ </dl>
182
+ )}
183
+ </div>
184
+ )}
185
+ </div>
186
+ );
187
187
  }