@lokvis/ui-react 0.1.1-beta.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 (152) 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 +79 -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 +39 -2
  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 +2 -1
  40. package/dist/components/Inspector.js.map +1 -1
  41. package/dist/components/ProgressBar.d.ts +18 -0
  42. package/dist/components/ProgressBar.d.ts.map +1 -0
  43. package/dist/components/ProgressBar.js +35 -0
  44. package/dist/components/ProgressBar.js.map +1 -0
  45. package/dist/components/StatusBar.d.ts +8 -2
  46. package/dist/components/StatusBar.d.ts.map +1 -1
  47. package/dist/components/StatusBar.js +59 -4
  48. package/dist/components/StatusBar.js.map +1 -1
  49. package/dist/components/ThemeToggle.d.ts +20 -0
  50. package/dist/components/ThemeToggle.d.ts.map +1 -0
  51. package/dist/components/ThemeToggle.js +60 -0
  52. package/dist/components/ThemeToggle.js.map +1 -0
  53. package/dist/components/WorkflowEditor.d.ts +24 -0
  54. package/dist/components/WorkflowEditor.d.ts.map +1 -0
  55. package/dist/components/WorkflowEditor.js +160 -0
  56. package/dist/components/WorkflowEditor.js.map +1 -0
  57. package/dist/components/WorkflowTemplates.d.ts +23 -0
  58. package/dist/components/WorkflowTemplates.d.ts.map +1 -0
  59. package/dist/components/WorkflowTemplates.js +32 -0
  60. package/dist/components/WorkflowTemplates.js.map +1 -0
  61. package/dist/components/Workspace.d.ts +48 -2
  62. package/dist/components/Workspace.d.ts.map +1 -1
  63. package/dist/components/Workspace.js +78 -4
  64. package/dist/components/Workspace.js.map +1 -1
  65. package/dist/data/workflow-templates.d.ts +42 -0
  66. package/dist/data/workflow-templates.d.ts.map +1 -0
  67. package/dist/data/workflow-templates.js +112 -0
  68. package/dist/data/workflow-templates.js.map +1 -0
  69. package/dist/hooks/useDebouncedRun.d.ts +49 -0
  70. package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
  71. package/dist/hooks/useDebouncedRun.js +117 -0
  72. package/dist/hooks/useDebouncedRun.js.map +1 -0
  73. package/dist/hooks/useLokvis.js +1 -1
  74. package/dist/hooks/useMediaQuery.d.ts +27 -0
  75. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  76. package/dist/hooks/useMediaQuery.js +41 -0
  77. package/dist/hooks/useMediaQuery.js.map +1 -0
  78. package/dist/hooks/useShareLink.d.ts +58 -0
  79. package/dist/hooks/useShareLink.d.ts.map +1 -0
  80. package/dist/hooks/useShareLink.js +120 -0
  81. package/dist/hooks/useShareLink.js.map +1 -0
  82. package/dist/hooks/useTheme.d.ts +30 -0
  83. package/dist/hooks/useTheme.d.ts.map +1 -0
  84. package/dist/hooks/useTheme.js +105 -0
  85. package/dist/hooks/useTheme.js.map +1 -0
  86. package/dist/hooks/useWorkflowProgress.d.ts +28 -0
  87. package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
  88. package/dist/hooks/useWorkflowProgress.js +25 -0
  89. package/dist/hooks/useWorkflowProgress.js.map +1 -0
  90. package/dist/hooks/useWorkflows.d.ts +86 -0
  91. package/dist/hooks/useWorkflows.d.ts.map +1 -0
  92. package/dist/hooks/useWorkflows.js +247 -0
  93. package/dist/hooks/useWorkflows.js.map +1 -0
  94. package/dist/index.d.ts +26 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +26 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/store/assets-slice.d.ts.map +1 -1
  99. package/dist/store/assets-slice.js +2 -0
  100. package/dist/store/assets-slice.js.map +1 -1
  101. package/dist/store/history-slice.d.ts +18 -0
  102. package/dist/store/history-slice.d.ts.map +1 -0
  103. package/dist/store/history-slice.js +42 -0
  104. package/dist/store/history-slice.js.map +1 -0
  105. package/dist/store/index.d.ts +1 -0
  106. package/dist/store/index.d.ts.map +1 -1
  107. package/dist/store/index.js +3 -0
  108. package/dist/store/index.js.map +1 -1
  109. package/dist/store/runtime-slice.d.ts +10 -2
  110. package/dist/store/runtime-slice.d.ts.map +1 -1
  111. package/dist/store/runtime-slice.js +77 -28
  112. package/dist/store/runtime-slice.js.map +1 -1
  113. package/dist/store/types.d.ts +70 -1
  114. package/dist/store/types.d.ts.map +1 -1
  115. package/dist/store/types.js +10 -0
  116. package/dist/store/types.js.map +1 -1
  117. package/dist/store/workflow-slice.d.ts +1 -1
  118. package/dist/store/workflow-slice.d.ts.map +1 -1
  119. package/dist/store/workflow-slice.js +128 -9
  120. package/dist/store/workflow-slice.js.map +1 -1
  121. package/package.json +9 -6
  122. package/src/components/AssetPanel.tsx +149 -8
  123. package/src/components/Canvas.tsx +79 -2
  124. package/src/components/CommandPalette.tsx +300 -0
  125. package/src/components/CompareSlider.tsx +186 -0
  126. package/src/components/DownloadPanel.tsx +252 -0
  127. package/src/components/ErrorBanner.tsx +95 -0
  128. package/src/components/ExifPanel.tsx +187 -0
  129. package/src/components/GlobalDropzone.tsx +220 -0
  130. package/src/components/HistoryPanel.tsx +231 -104
  131. package/src/components/Inspector.tsx +4 -0
  132. package/src/components/ProgressBar.tsx +104 -0
  133. package/src/components/StatusBar.tsx +124 -5
  134. package/src/components/ThemeToggle.tsx +122 -0
  135. package/src/components/WorkflowEditor.tsx +382 -0
  136. package/src/components/WorkflowTemplates.tsx +101 -0
  137. package/src/components/Workspace.tsx +205 -8
  138. package/src/data/workflow-templates.ts +137 -0
  139. package/src/hooks/useDebouncedRun.ts +151 -0
  140. package/src/hooks/useLokvis.ts +1 -1
  141. package/src/hooks/useMediaQuery.ts +53 -0
  142. package/src/hooks/useShareLink.ts +158 -0
  143. package/src/hooks/useTheme.ts +116 -0
  144. package/src/hooks/useWorkflowProgress.ts +41 -0
  145. package/src/hooks/useWorkflows.ts +326 -0
  146. package/src/index.ts +32 -0
  147. package/src/store/assets-slice.ts +2 -0
  148. package/src/store/history-slice.ts +65 -0
  149. package/src/store/index.ts +3 -0
  150. package/src/store/runtime-slice.ts +89 -32
  151. package/src/store/types.ts +71 -1
  152. package/src/store/workflow-slice.ts +139 -10
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * AssetPanel - 左侧资产面板
3
3
  *
4
- * 紧凑布局:section header + 上传按钮 + 资产列表。
5
- * 支持拖拽导入、点击选择、缩略图预览。
4
+ * 紧凑布局:section header + 上传按钮 + 筛选 + 资产列表。
5
+ * 支持拖拽导入、点击选择、缩略图预览、按类型/关键词筛选、删除(W6.5)。
6
6
  */
7
7
 
8
8
  import * as React from 'react';
9
+ import type { AssetType } from '@lokvis/schema';
9
10
  import { Icon } from '@lokvis/ui-core';
10
11
  import { useWorkspaceStore } from '../store/index.js';
11
12
 
@@ -13,6 +14,17 @@ export interface AssetPanelProps {
13
14
  className?: string;
14
15
  }
15
16
 
17
+ /** 类型筛选 chip 显示名 */
18
+ const TYPE_LABEL: Record<AssetType, string> = {
19
+ image: 'Image',
20
+ video: 'Video',
21
+ audio: 'Audio',
22
+ pdf: 'PDF',
23
+ text: 'Text',
24
+ data: 'Data',
25
+ unknown: 'Other',
26
+ };
27
+
16
28
  export function AssetPanel({ className = '' }: AssetPanelProps) {
17
29
  const assets = useWorkspaceStore((s) => s.assets);
18
30
  const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
@@ -24,6 +36,11 @@ export function AssetPanel({ className = '' }: AssetPanelProps) {
24
36
  const runtime = useWorkspaceStore((s) => s.runtime);
25
37
 
26
38
  const [dragging, setDragging] = React.useState(false);
39
+ // W6.5 筛选:类型 + 关键词(纯 UI 状态,不入 store)
40
+ const [filterType, setFilterType] = React.useState<'all' | AssetType>('all');
41
+ const [searchQuery, setSearchQuery] = React.useState('');
42
+ // B2 修复:跟踪 in-flight exportAsset,避免同一 asset 跨 effect 重跑并发发起
43
+ const inflightRef = React.useRef<Set<string>>(new Set());
27
44
 
28
45
  async function handleFiles(files: FileList | File[] | null) {
29
46
  if (!files) return;
@@ -32,24 +49,69 @@ export function AssetPanel({ className = '' }: AssetPanelProps) {
32
49
  }
33
50
 
34
51
  // 自动生成缩略图
52
+ // B2 修复:
53
+ // - cancelled flag:effect 重跑/卸载时,await 完成的 exportAsset 不再创建
54
+ // ObjectURL 也不 setThumbnail,避免覆盖更新值与孤儿 URL
55
+ // - inflightRef:同一 asset 在上一轮 effect 还在 await 时,新一轮 effect
56
+ // 不再重复发起(批量导入 assets 频繁变化场景)
35
57
  React.useEffect(() => {
36
58
  if (!runtime) return;
59
+ let cancelled = false;
60
+ const inflight = inflightRef.current;
37
61
  for (const asset of assets) {
38
62
  if (thumbnails[asset.id]) continue;
39
63
  if (asset.type !== 'image') continue;
64
+ if (inflight.has(asset.id)) continue;
65
+ inflight.add(asset.id);
40
66
  (async () => {
41
67
  try {
42
68
  const blob = await runtime.exportAsset(asset.id);
69
+ // effect 已过期或资产已删除:不创建 ObjectURL(零泄漏)
70
+ if (cancelled) return;
43
71
  const url = URL.createObjectURL(blob);
72
+ if (cancelled) {
73
+ // 双重检查:await 与 createObjectURL 之间可能被取消
74
+ URL.revokeObjectURL(url);
75
+ return;
76
+ }
44
77
  setThumbnail(asset.id, url);
45
78
  } catch {
46
- // ignore
79
+ // ignore export 失败
80
+ } finally {
81
+ inflight.delete(asset.id);
47
82
  }
48
83
  })();
49
84
  }
85
+ return () => {
86
+ cancelled = true;
87
+ };
50
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
51
89
  }, [assets, runtime]);
52
90
 
91
+ // 资产中实际存在的类型(仅展示有意义的 chip,避免空类型噪音)
92
+ const availableTypes = React.useMemo(() => {
93
+ const set = new Set<AssetType>();
94
+ for (const a of assets) set.add(a.type);
95
+ return Array.from(set);
96
+ }, [assets]);
97
+
98
+ // 筛选后的资产列表
99
+ const filteredAssets = React.useMemo(() => {
100
+ let result = assets;
101
+ if (filterType !== 'all') {
102
+ result = result.filter((a) => a.type === filterType);
103
+ }
104
+ const q = searchQuery.trim().toLowerCase();
105
+ if (q) {
106
+ result = result.filter(
107
+ (a) =>
108
+ a.metadata.format.toLowerCase().includes(q) ||
109
+ a.metadata.mimeType.toLowerCase().includes(q)
110
+ );
111
+ }
112
+ return result;
113
+ }, [assets, filterType, searchQuery]);
114
+
53
115
  return (
54
116
  <aside
55
117
  className={`flex w-56 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 ${className}`}
@@ -69,7 +131,11 @@ export function AssetPanel({ className = '' }: AssetPanelProps) {
69
131
  <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
70
132
  Assets
71
133
  </span>
72
- <span className="text-[11px] tabular-nums text-zinc-400">{assets.length}</span>
134
+ <span className="text-[11px] tabular-nums text-zinc-400">
135
+ {filterType === 'all' && !searchQuery
136
+ ? assets.length
137
+ : `${filteredAssets.length}/${assets.length}`}
138
+ </span>
73
139
  </div>
74
140
 
75
141
  {/* Upload zone */}
@@ -94,22 +160,97 @@ export function AssetPanel({ className = '' }: AssetPanelProps) {
94
160
  </span>
95
161
  </label>
96
162
 
163
+ {/* W6.5 筛选栏:仅当有资产时显示 */}
164
+ {assets.length > 0 && (
165
+ <div className="px-2 pb-2 space-y-1.5">
166
+ {/* 类型 chips:m10 radiogroup 语义,屏幕阅读器识别为单选组 */}
167
+ <div
168
+ role="radiogroup"
169
+ aria-label="Filter by type"
170
+ className="flex flex-wrap gap-1"
171
+ >
172
+ <button
173
+ type="button"
174
+ role="radio"
175
+ aria-checked={filterType === 'all'}
176
+ onClick={() => setFilterType('all')}
177
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
178
+ filterType === 'all'
179
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
180
+ : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
181
+ }`}
182
+ >
183
+ All
184
+ </button>
185
+ {availableTypes.map((t) => (
186
+ <button
187
+ key={t}
188
+ type="button"
189
+ role="radio"
190
+ aria-checked={filterType === t}
191
+ onClick={() => setFilterType(t)}
192
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
193
+ filterType === t
194
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
195
+ : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
196
+ }`}
197
+ >
198
+ {TYPE_LABEL[t]}
199
+ </button>
200
+ ))}
201
+ </div>
202
+ {/* 搜索框:m4 文案改为与实际过滤行为一致(按 format/mimeType) */}
203
+ <div className="relative">
204
+ <Icon
205
+ size={11}
206
+ className="absolute left-1.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
207
+ >
208
+ <circle cx="10" cy="10" r="6" />
209
+ <path d="m20 20-5-5" />
210
+ </Icon>
211
+ <input
212
+ type="text"
213
+ value={searchQuery}
214
+ onChange={(e) => setSearchQuery(e.target.value)}
215
+ placeholder="Filter by type..."
216
+ aria-label="Filter assets by format"
217
+ className="w-full rounded bg-zinc-100 py-1 pl-6 pr-1.5 text-[10px] text-zinc-700 placeholder:text-zinc-400 focus:outline-none focus:ring-1 focus:ring-indigo-300 dark:bg-zinc-800 dark:text-zinc-200 dark:placeholder:text-zinc-500"
218
+ />
219
+ </div>
220
+ </div>
221
+ )}
222
+
97
223
  {/* Asset list */}
98
224
  <div className="flex-1 overflow-y-auto px-2 pb-2">
99
225
  {assets.length === 0 ? (
100
226
  <p className="px-1 py-4 text-center text-[10px] text-zinc-400">
101
227
  No assets imported
102
228
  </p>
229
+ ) : filteredAssets.length === 0 ? (
230
+ <p className="px-1 py-4 text-center text-[10px] text-zinc-400">
231
+ No assets match filter
232
+ </p>
103
233
  ) : (
104
234
  <ul className="space-y-1">
105
- {assets.map((asset) => {
235
+ {filteredAssets.map((asset) => {
106
236
  const selected = asset.id === selectedAssetId;
107
237
  const thumb = thumbnails[asset.id];
108
238
  return (
109
239
  <li
110
240
  key={asset.id}
241
+ // M3: 键盘可达 —— tabIndex + role + onKeyDown(Enter/Space 选中)
242
+ tabIndex={0}
243
+ role="button"
244
+ aria-pressed={selected}
245
+ aria-label={`Select asset ${asset.metadata.format}`}
111
246
  onClick={() => selectAsset(asset.id)}
112
- className={`group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all ${
247
+ onKeyDown={(e) => {
248
+ if (e.key === 'Enter' || e.key === ' ') {
249
+ e.preventDefault();
250
+ selectAsset(asset.id);
251
+ }
252
+ }}
253
+ className={`group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all focus:outline-none focus:ring-2 focus:ring-indigo-300 ${
113
254
  selected
114
255
  ? 'bg-indigo-50 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:ring-indigo-800'
115
256
  : 'hover:bg-zinc-50 dark:hover:bg-zinc-800'
@@ -136,8 +277,8 @@ export function AssetPanel({ className = '' }: AssetPanelProps) {
136
277
  e.stopPropagation();
137
278
  void removeAsset(asset.id);
138
279
  }}
139
- className="shrink-0 rounded p-0.5 text-zinc-300 opacity-0 transition-all hover:bg-red-50 hover:text-red-500 group-hover:opacity-100 dark:text-zinc-600 dark:hover:bg-red-950/30"
140
- aria-label="Remove asset"
280
+ className="shrink-0 rounded p-0.5 text-zinc-300 opacity-0 transition-all hover:bg-red-50 hover:text-red-500 focus:opacity-100 group-hover:opacity-100 dark:text-zinc-600 dark:hover:bg-red-950/30"
281
+ aria-label={`Remove asset ${asset.metadata.format}`}
141
282
  >
142
283
  <Icon size={12}><path d="M6 18L18 6M6 6l12 12" /></Icon>
143
284
  </button>
@@ -3,25 +3,64 @@
3
3
  *
4
4
  * 只负责展示当前选中资产的预览,以及空状态引导。
5
5
  * 工作流节点链已移至独立的 PipelineBar 组件。
6
+ *
7
+ * W9.4: 当存在 lastOutputIds 时,可切换到 CompareSlider 模式,
8
+ * 并排展示 before/after。
6
9
  */
7
10
 
8
11
  import * as React from 'react';
9
12
  import { Icon } from '@lokvis/ui-core';
10
13
  import { useWorkspaceStore } from '../store/index.js';
14
+ import { CompareSlider } from './CompareSlider.js';
11
15
 
12
16
  export interface CanvasProps {
13
17
  className?: string;
18
+ /**
19
+ * 是否启用 before/after 对比模式(W9.4)。
20
+ * 默认 true;关闭后即使有 outputs 也只显示单图预览。
21
+ */
22
+ enableCompare?: boolean;
14
23
  }
15
24
 
16
- export function Canvas({ className = '' }: CanvasProps) {
25
+ export function Canvas({ className = '', enableCompare = true }: CanvasProps) {
17
26
  const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
18
27
  const assets = useWorkspaceStore((s) => s.assets);
19
28
  const thumbnails = useWorkspaceStore((s) => s.thumbnails);
20
29
  const importFiles = useWorkspaceStore((s) => s.importFiles);
30
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
31
+ const selectedOutputId = useWorkspaceStore((s) => s.selectedOutputId);
21
32
 
22
33
  const selected = assets.find((a) => a.id === selectedAssetId);
23
34
  const preview = selected ? thumbnails[selected.id] : undefined;
24
35
 
36
+ // W9.4 是否有可对比的输出
37
+ const outputId = selectedOutputId ?? lastOutputIds[0] ?? null;
38
+ const outputThumbnail = outputId ? thumbnails[outputId] : undefined;
39
+ const canCompare = enableCompare && !!(preview && outputThumbnail);
40
+
41
+ // W9.4 默认开启 compare 模式:当 outputs 生成后,用户首次希望对比
42
+ // 切换状态由用户控制,直到下次 outputs 重新生成时再默认开启
43
+ const [compareMode, setCompareMode] = React.useState(false);
44
+ // 记录上次已自动切到 compare 模式的 outputId。
45
+ // 修复 review:原 effect 依赖 [lastOutputIds, preview, outputThumbnail],
46
+ // 当用户手动切到 Single 后再切换选中资产,preview 变化会触发 effect
47
+ // 把 compareMode 强制重置为 true,覆盖用户选择。
48
+ // 现在仅在 outputId 真正变化(新 run 完成 / 切换输出)且 preview/outputThumbnail
49
+ // 都就绪时才自动切换;同一 outputId 下 preview / outputThumbnail 的异步变化
50
+ // 不会覆盖用户已选择的 Single 模式。
51
+ const lastAutoSwitchedOutputId = React.useRef<string | null>(null);
52
+ React.useEffect(() => {
53
+ if (
54
+ outputId !== null &&
55
+ outputId !== lastAutoSwitchedOutputId.current &&
56
+ preview &&
57
+ outputThumbnail
58
+ ) {
59
+ lastAutoSwitchedOutputId.current = outputId;
60
+ setCompareMode(true);
61
+ }
62
+ }, [outputId, preview, outputThumbnail]);
63
+
25
64
  // 全局拖拽支持(直接拖到画布区)
26
65
  const [dragOver, setDragOver] = React.useState(false);
27
66
 
@@ -32,6 +71,9 @@ export function Canvas({ className = '' }: CanvasProps) {
32
71
  onDragLeave={() => setDragOver(false)}
33
72
  onDrop={(e) => {
34
73
  e.preventDefault();
74
+ // stopPropagation 防止事件冒泡到 window 的 GlobalDropzone 监听,
75
+ // 否则文件会被导入两次(Canvas 处理一次 + GlobalDropzone 再处理一次)
76
+ e.stopPropagation();
35
77
  setDragOver(false);
36
78
  if (e.dataTransfer.files.length > 0) {
37
79
  void importFiles(Array.from(e.dataTransfer.files));
@@ -49,6 +91,36 @@ export function Canvas({ className = '' }: CanvasProps) {
49
91
  }}
50
92
  />
51
93
 
94
+ {/* W9.4 Compare 模式切换按钮(右上) */}
95
+ {canCompare && (
96
+ <div className="absolute top-2 right-2 z-20 flex items-center gap-1 rounded-md bg-white/90 p-0.5 shadow-sm backdrop-blur-sm dark:bg-zinc-900/90">
97
+ <button
98
+ type="button"
99
+ onClick={() => setCompareMode(false)}
100
+ aria-pressed={!compareMode}
101
+ className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
102
+ !compareMode
103
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
104
+ : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
105
+ }`}
106
+ >
107
+ Single
108
+ </button>
109
+ <button
110
+ type="button"
111
+ onClick={() => setCompareMode(true)}
112
+ aria-pressed={compareMode}
113
+ className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
114
+ compareMode
115
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
116
+ : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
117
+ }`}
118
+ >
119
+ Compare
120
+ </button>
121
+ </div>
122
+ )}
123
+
52
124
  {dragOver && (
53
125
  <div className="absolute inset-4 z-10 rounded-xl border-2 border-dashed border-indigo-400 bg-indigo-50/80 flex items-center justify-center dark:border-indigo-600 dark:bg-indigo-950/60">
54
126
  <div className="text-center">
@@ -58,7 +130,12 @@ export function Canvas({ className = '' }: CanvasProps) {
58
130
  </div>
59
131
  )}
60
132
 
61
- {selected ? (
133
+ {/* W9.4 Compare 模式:展示 before/after 滑块 */}
134
+ {compareMode && canCompare ? (
135
+ <div className="relative flex max-h-[calc(100%-4rem)] max-w-[calc(100%-4rem)] items-center justify-center">
136
+ <CompareSlider className="max-h-[calc(100vh-12rem)] max-w-full" />
137
+ </div>
138
+ ) : selected ? (
62
139
  preview ? (
63
140
  <div className="relative">
64
141
  <img
@@ -0,0 +1,300 @@
1
+ /**
2
+ * CommandPalette - ⌘K 工具选择器(W9.2)
3
+ *
4
+ * Command Palette 风格的快捷工具入口:
5
+ * - ⌘K / Ctrl+K 打开
6
+ * - ESC 关闭
7
+ * - 输入关键词过滤能力列表
8
+ * - ↑↓ 选择,Enter 添加到工作流,or 鼠标点击
9
+ *
10
+ * 数据源:`store.capabilities`,与 Inspector 右侧能力列表同源。
11
+ * 选中后调用 `store.addNode(cap.name)` 添加到工作流,并关闭面板。
12
+ *
13
+ * 实现:基于 ui-core 的 Dialog 组件提供模态基底 + 焦点 trap。
14
+ */
15
+
16
+ import * as React from 'react';
17
+ import { Dialog, Icon } from '@lokvis/ui-core';
18
+ import type { Capability } from '@lokvis/schema';
19
+ import { useWorkspaceStore } from '../store/index.js';
20
+
21
+ export interface CommandPaletteProps {
22
+ /** 是否打开(受控) */
23
+ open: boolean;
24
+ /** 关闭回调 */
25
+ onClose: () => void;
26
+ className?: string;
27
+ }
28
+
29
+ export function CommandPalette({ open, onClose, className = '' }: CommandPaletteProps) {
30
+ const capabilities = useWorkspaceStore((s) => s.capabilities);
31
+ const addNode = useWorkspaceStore((s) => s.addNode);
32
+ const undo = useWorkspaceStore((s) => s.undo);
33
+ const redo = useWorkspaceStore((s) => s.redo);
34
+ const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
35
+ const running = useWorkspaceStore((s) => s.running);
36
+
37
+ const [query, setQuery] = React.useState('');
38
+ const [activeIndex, setActiveIndex] = React.useState(0);
39
+ const inputRef = React.useRef<HTMLInputElement>(null);
40
+ const listRef = React.useRef<HTMLUListElement>(null);
41
+
42
+ // 命令项:capability 添加 + 工作流操作
43
+ type Command =
44
+ | { kind: 'capability'; cap: Capability }
45
+ | { kind: 'action'; id: string; label: string; description: string; run: () => void; disabled?: boolean };
46
+
47
+ const commands: Command[] = React.useMemo(() => {
48
+ const caps = capabilities.map((cap) => ({ kind: 'capability' as const, cap }));
49
+ const actions: Command[] = [
50
+ {
51
+ kind: 'action',
52
+ id: 'undo',
53
+ label: 'Undo',
54
+ description: '回退到上一步历史',
55
+ run: () => void undo(),
56
+ disabled: false,
57
+ },
58
+ {
59
+ kind: 'action',
60
+ id: 'redo',
61
+ label: 'Redo',
62
+ description: '重做一步',
63
+ run: () => void redo(),
64
+ disabled: false,
65
+ },
66
+ {
67
+ kind: 'action',
68
+ id: 'clear',
69
+ label: 'Clear Workflow',
70
+ description: '清空当前工作流节点',
71
+ run: () => clearWorkflow(),
72
+ disabled: running,
73
+ },
74
+ ];
75
+ return [...caps, ...actions];
76
+ }, [capabilities, undo, redo, clearWorkflow, running]);
77
+
78
+ const filtered = React.useMemo(() => {
79
+ const q = query.trim().toLowerCase();
80
+ if (!q) return commands;
81
+ return commands.filter((cmd) => {
82
+ if (cmd.kind === 'capability') {
83
+ return (
84
+ cmd.cap.name.toLowerCase().includes(q) ||
85
+ cmd.cap.description.toLowerCase().includes(q)
86
+ );
87
+ }
88
+ return cmd.label.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q);
89
+ });
90
+ }, [commands, query]);
91
+
92
+ // 打开时重置 query + activeIndex + 聚焦输入
93
+ React.useEffect(() => {
94
+ if (!open) return;
95
+ setQuery('');
96
+ setActiveIndex(0);
97
+ // Dialog 聚焦首个可聚焦元素(input)后,input ref 才有值
98
+ // 用 timer 句柄 + cleanup 避免卸载后 setState 警告
99
+ const t = setTimeout(() => inputRef.current?.focus(), 0);
100
+ return () => clearTimeout(t);
101
+ }, [open]);
102
+
103
+ // filtered 变化时校正 activeIndex 越界
104
+ React.useEffect(() => {
105
+ if (activeIndex >= filtered.length) {
106
+ setActiveIndex(filtered.length === 0 ? 0 : filtered.length - 1);
107
+ }
108
+ }, [filtered.length, activeIndex]);
109
+
110
+ // 滚动 active 项进入视区
111
+ React.useEffect(() => {
112
+ if (!open || !listRef.current) return;
113
+ const active = listRef.current.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
114
+ active?.scrollIntoView({ block: 'nearest' });
115
+ }, [activeIndex, open]);
116
+
117
+ function handleSelect(cmd: Command) {
118
+ if (cmd.kind === 'capability') {
119
+ addNode(cmd.cap.name);
120
+ } else {
121
+ if (cmd.disabled) return;
122
+ cmd.run();
123
+ }
124
+ onClose();
125
+ }
126
+
127
+ function handleKeyDown(e: React.KeyboardEvent) {
128
+ if (e.key === 'ArrowDown') {
129
+ e.preventDefault();
130
+ setActiveIndex((i) => (i + 1) % Math.max(1, filtered.length));
131
+ } else if (e.key === 'ArrowUp') {
132
+ e.preventDefault();
133
+ setActiveIndex((i) => (i - 1 + filtered.length) % Math.max(1, filtered.length));
134
+ } else if (e.key === 'Enter') {
135
+ e.preventDefault();
136
+ const cmd = filtered[activeIndex];
137
+ if (cmd) handleSelect(cmd);
138
+ }
139
+ }
140
+
141
+ return (
142
+ <Dialog open={open} onClose={onClose} size="md" closeOnOverlay>
143
+ <div className={`-mx-5 -my-5 ${className}`}>
144
+ {/* 搜索输入 */}
145
+ <div className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
146
+ <Icon size={16} className="text-zinc-400">
147
+ <circle cx="11" cy="11" r="8" />
148
+ <path d="m21 21-4.3-4.3" />
149
+ </Icon>
150
+ <input
151
+ ref={inputRef}
152
+ type="text"
153
+ value={query}
154
+ onChange={(e) => setQuery(e.target.value)}
155
+ onKeyDown={handleKeyDown}
156
+ placeholder="Search commands or capabilities..."
157
+ aria-label="Search commands"
158
+ className="flex-1 bg-transparent text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none dark:text-zinc-100"
159
+ autoComplete="off"
160
+ spellCheck={false}
161
+ />
162
+ <kbd className="rounded border border-zinc-200 px-1.5 py-0.5 text-[9px] font-medium text-zinc-400 dark:border-zinc-700 dark:text-zinc-500">
163
+ ESC
164
+ </kbd>
165
+ </div>
166
+
167
+ {/* 命令列表 */}
168
+ <ul
169
+ ref={listRef}
170
+ className="max-h-80 overflow-y-auto py-1"
171
+ role="listbox"
172
+ aria-label="Available commands"
173
+ >
174
+ {filtered.length === 0 ? (
175
+ <li className="px-4 py-6 text-center text-xs text-zinc-400">
176
+ No matching commands
177
+ </li>
178
+ ) : (
179
+ filtered.map((cmd, i) => {
180
+ const isActive = i === activeIndex;
181
+ const isCap = cmd.kind === 'capability';
182
+ const primary = isCap ? cmd.cap.name : cmd.label;
183
+ const secondary = isCap ? cmd.cap.description : cmd.description;
184
+ const disabled = !isCap && cmd.disabled;
185
+ return (
186
+ <li
187
+ key={isCap ? `cap:${cmd.cap.name}` : `act:${cmd.id}`}
188
+ data-index={i}
189
+ role="option"
190
+ aria-selected={isActive}
191
+ aria-disabled={disabled}
192
+ >
193
+ <button
194
+ type="button"
195
+ onMouseEnter={() => setActiveIndex(i)}
196
+ onClick={() => !disabled && handleSelect(cmd)}
197
+ disabled={disabled}
198
+ className={`flex w-full items-center gap-3 px-4 py-2 text-left transition-colors ${
199
+ isActive && !disabled
200
+ ? 'bg-indigo-50 dark:bg-indigo-950/30'
201
+ : ''
202
+ } ${disabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50'}`}
203
+ >
204
+ <span
205
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded ${
206
+ isCap
207
+ ? 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
208
+ : 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950/50 dark:text-indigo-400'
209
+ }`}
210
+ >
211
+ <Icon size={12} strokeWidth={2}>
212
+ {isCap ? (
213
+ <path d="M12 4.5v15m7.5-7.5h-15" />
214
+ ) : (
215
+ <path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z" />
216
+ )}
217
+ </Icon>
218
+ </span>
219
+ <div className="min-w-0 flex-1">
220
+ <div className="flex items-center justify-between gap-2">
221
+ <span className="truncate text-xs font-medium text-zinc-900 dark:text-zinc-100">
222
+ {primary}
223
+ </span>
224
+ {isCap && (
225
+ <span className="shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
226
+ {cmd.cap.performance}
227
+ </span>
228
+ )}
229
+ </div>
230
+ <p className="truncate text-[10px] text-zinc-500 dark:text-zinc-400">
231
+ {secondary}
232
+ </p>
233
+ </div>
234
+ </button>
235
+ </li>
236
+ );
237
+ })
238
+ )}
239
+ </ul>
240
+
241
+ {/* 底部提示 */}
242
+ <div className="flex items-center justify-between border-t border-zinc-200 px-4 py-2 text-[10px] text-zinc-400 dark:border-zinc-800">
243
+ <span className="flex items-center gap-1">
244
+ <kbd className="rounded border border-zinc-200 px-1 dark:border-zinc-700">↑↓</kbd>
245
+ <span>navigate</span>
246
+ <kbd className="ml-2 rounded border border-zinc-200 px-1 dark:border-zinc-700">↵</kbd>
247
+ <span>select</span>
248
+ </span>
249
+ <span>{filtered.length} command{filtered.length !== 1 ? 's' : ''}</span>
250
+ </div>
251
+ </div>
252
+ </Dialog>
253
+ );
254
+ }
255
+
256
+ /**
257
+ * useCommandPalette - 在组件中注册 ⌘K / Ctrl+K 全局快捷键打开面板。
258
+ *
259
+ * 返回 [open, setOpen]。在 useEffect 中绑定 keydown 监听。
260
+ * 兼容 macOS(⌘K)与 Windows/Linux(Ctrl+K)。Shift+⌘K / Ctrl+Shift+K 也触发,
261
+ * 方便用户在 ⌘K 被浏览器拦截时使用。
262
+ *
263
+ * @example
264
+ * ```tsx
265
+ * const [open, setOpen] = useCommandPalette();
266
+ * // 或禁用快捷键(仍可受控使用 open/setOpen):
267
+ * const [open, setOpen] = useCommandPalette({ enabled: false });
268
+ * return (
269
+ * <>
270
+ * <CommandPalette open={open} onClose={() => setOpen(false)} />
271
+ * </>
272
+ * );
273
+ * ```
274
+ */
275
+ export interface UseCommandPaletteOptions {
276
+ /** 是否注册 ⌘K 全局快捷键(默认 true)。设为 false 时仍可受控使用 open/setOpen */
277
+ enabled?: boolean;
278
+ }
279
+
280
+ export function useCommandPalette(
281
+ options: UseCommandPaletteOptions = {}
282
+ ): [boolean, React.Dispatch<React.SetStateAction<boolean>>] {
283
+ const { enabled = true } = options;
284
+ const [open, setOpen] = React.useState(false);
285
+
286
+ React.useEffect(() => {
287
+ if (!enabled) return;
288
+ const onKey = (e: KeyboardEvent) => {
289
+ // ⌘K (mac) / Ctrl+K (win/linux),忽略纯 K(避免拦截输入)
290
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
291
+ e.preventDefault();
292
+ setOpen((v) => !v);
293
+ }
294
+ };
295
+ document.addEventListener('keydown', onKey);
296
+ return () => document.removeEventListener('keydown', onKey);
297
+ }, [enabled]);
298
+
299
+ return [open, setOpen];
300
+ }