@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
@@ -3,24 +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';
12
+ import { Icon } from '@lokvis/ui-core';
9
13
  import { useWorkspaceStore } from '../store/index.js';
14
+ import { CompareSlider } from './CompareSlider.js';
10
15
 
11
16
  export interface CanvasProps {
12
17
  className?: string;
18
+ /**
19
+ * 是否启用 before/after 对比模式(W9.4)。
20
+ * 默认 true;关闭后即使有 outputs 也只显示单图预览。
21
+ */
22
+ enableCompare?: boolean;
13
23
  }
14
24
 
15
- export function Canvas({ className = '' }: CanvasProps) {
25
+ export function Canvas({ className = '', enableCompare = true }: CanvasProps) {
16
26
  const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
17
27
  const assets = useWorkspaceStore((s) => s.assets);
18
28
  const thumbnails = useWorkspaceStore((s) => s.thumbnails);
19
29
  const importFiles = useWorkspaceStore((s) => s.importFiles);
30
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
31
+ const selectedOutputId = useWorkspaceStore((s) => s.selectedOutputId);
20
32
 
21
33
  const selected = assets.find((a) => a.id === selectedAssetId);
22
34
  const preview = selected ? thumbnails[selected.id] : undefined;
23
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
+
24
64
  // 全局拖拽支持(直接拖到画布区)
25
65
  const [dragOver, setDragOver] = React.useState(false);
26
66
 
@@ -31,6 +71,9 @@ export function Canvas({ className = '' }: CanvasProps) {
31
71
  onDragLeave={() => setDragOver(false)}
32
72
  onDrop={(e) => {
33
73
  e.preventDefault();
74
+ // stopPropagation 防止事件冒泡到 window 的 GlobalDropzone 监听,
75
+ // 否则文件会被导入两次(Canvas 处理一次 + GlobalDropzone 再处理一次)
76
+ e.stopPropagation();
34
77
  setDragOver(false);
35
78
  if (e.dataTransfer.files.length > 0) {
36
79
  void importFiles(Array.from(e.dataTransfer.files));
@@ -48,16 +91,51 @@ export function Canvas({ className = '' }: CanvasProps) {
48
91
  }}
49
92
  />
50
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
+
51
124
  {dragOver && (
52
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">
53
126
  <div className="text-center">
54
- <svg className="mx-auto h-8 w-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" 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"/></svg>
127
+ <Icon size={32} className="mx-auto text-indigo-500" strokeWidth={1.5}><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" /></Icon>
55
128
  <p className="mt-2 text-sm font-medium text-indigo-600 dark:text-indigo-400">Drop files to import</p>
56
129
  </div>
57
130
  </div>
58
131
  )}
59
132
 
60
- {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 ? (
61
139
  preview ? (
62
140
  <div className="relative">
63
141
  <img
@@ -89,9 +167,9 @@ export function Canvas({ className = '' }: CanvasProps) {
89
167
  ) : (
90
168
  <div className="relative flex flex-col items-center gap-5 px-6 text-center">
91
169
  <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200/80 dark:bg-zinc-800 dark:ring-zinc-700">
92
- <svg className="h-9 w-9 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
93
- <path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
94
- </svg>
170
+ <Icon size={36} className="text-zinc-300 dark:text-zinc-600" strokeWidth={1.5}>
171
+ <path d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
172
+ </Icon>
95
173
  </div>
96
174
  <div>
97
175
  <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">No asset selected</p>
@@ -103,7 +181,7 @@ export function Canvas({ className = '' }: CanvasProps) {
103
181
  </div>
104
182
  {assets.length === 0 && (
105
183
  <label className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-medium text-white shadow-sm cursor-pointer transition-colors hover:bg-indigo-500">
106
- <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
184
+ <Icon size={14}><path d="M12 4.5v15m7.5-7.5h-15" /></Icon>
107
185
  Import Files
108
186
  <input
109
187
  type="file"
@@ -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
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CompareSlider - before/after 对比滑块(W9.4)
3
+ *
4
+ * 把输入资产(原)与输出资产(处理后)叠加展示,通过可拖拽的分隔条左右揭示
5
+ * before / after 两张图。用于直观展示工作流处理效果。
6
+ *
7
+ * 交互:
8
+ * - 鼠标拖拽分隔条(或点击容器任意位置)调整揭示比例
9
+ * - 触摸:touchmove 同步
10
+ * - 键盘:← → 调整 5% 步长(聚焦分隔条时)
11
+ *
12
+ * 数据来源:
13
+ * - before: `store.selectedAssetId` 对应的 thumbnails(由 AssetPanel 自动生成)
14
+ * - after: `store.selectedOutputId` 对应的 thumbnails
15
+ * - 任一缺失则不渲染(返回 null)
16
+ *
17
+ * 注:本组件只负责"展示对比",不负责生成缩略图。缩略图由 AssetPanel
18
+ * 的 effect 统一生成并存入 store.thumbnails,本组件按 assetId 取用。
19
+ */
20
+
21
+ import * as React from 'react';
22
+ import { Icon } from '@lokvis/ui-core';
23
+ import { useWorkspaceStore } from '../store/index.js';
24
+
25
+ export interface CompareSliderProps {
26
+ className?: string;
27
+ }
28
+
29
+ export function CompareSlider({ className = '' }: CompareSliderProps) {
30
+ const assets = useWorkspaceStore((s) => s.assets);
31
+ const thumbnails = useWorkspaceStore((s) => s.thumbnails);
32
+ const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
33
+ const selectedOutputId = useWorkspaceStore((s) => s.selectedOutputId);
34
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
35
+
36
+ // before:用户选中的输入资产;after:用户选中的输出资产(默认第一个)
37
+ const beforeId = selectedAssetId;
38
+ const afterId = selectedOutputId ?? lastOutputIds[0] ?? null;
39
+
40
+ const beforeAsset = beforeId ? assets.find((a) => a.id === beforeId) : null;
41
+ const afterAsset = afterId ? assets.find((a) => a.id === afterId) : null;
42
+ const beforeUrl = beforeId ? thumbnails[beforeId] : undefined;
43
+ const afterUrl = afterId ? thumbnails[afterId] : undefined;
44
+
45
+ const containerRef = React.useRef<HTMLDivElement>(null);
46
+ const [pos, setPos] = React.useState(50); // 0-100,分隔条位置百分比
47
+ const [dragging, setDragging] = React.useState(false);
48
+
49
+ // 切换资产时重置位置到中央
50
+ React.useEffect(() => {
51
+ setPos(50);
52
+ }, [beforeId, afterId]);
53
+
54
+ const updatePos = React.useCallback((clientX: number) => {
55
+ const el = containerRef.current;
56
+ if (!el) return;
57
+ const rect = el.getBoundingClientRect();
58
+ const ratio = (clientX - rect.left) / rect.width;
59
+ setPos(Math.max(0, Math.min(100, ratio * 100)));
60
+ }, []);
61
+
62
+ // 鼠标拖拽
63
+ const handleMouseDown = (e: React.MouseEvent) => {
64
+ e.preventDefault();
65
+ setDragging(true);
66
+ updatePos(e.clientX);
67
+ };
68
+ React.useEffect(() => {
69
+ if (!dragging) return;
70
+ const onMove = (e: MouseEvent) => updatePos(e.clientX);
71
+ const onUp = () => setDragging(false);
72
+ document.addEventListener('mousemove', onMove);
73
+ document.addEventListener('mouseup', onUp);
74
+ return () => {
75
+ document.removeEventListener('mousemove', onMove);
76
+ document.removeEventListener('mouseup', onUp);
77
+ };
78
+ }, [dragging, updatePos]);
79
+
80
+ // 触摸拖拽
81
+ const handleTouchStart = (e: React.TouchEvent) => {
82
+ setDragging(true);
83
+ if (e.touches[0]) updatePos(e.touches[0].clientX);
84
+ };
85
+ React.useEffect(() => {
86
+ if (!dragging) return;
87
+ const onMove = (e: TouchEvent) => {
88
+ if (e.touches[0]) updatePos(e.touches[0].clientX);
89
+ };
90
+ const onEnd = () => setDragging(false);
91
+ document.addEventListener('touchmove', onMove, { passive: true });
92
+ document.addEventListener('touchend', onEnd);
93
+ return () => {
94
+ document.removeEventListener('touchmove', onMove);
95
+ document.removeEventListener('touchend', onEnd);
96
+ };
97
+ }, [dragging, updatePos]);
98
+
99
+ // 键盘:← → 调整 5%
100
+ const handleKeyDown = (e: React.KeyboardEvent) => {
101
+ if (e.key === 'ArrowLeft') {
102
+ e.preventDefault();
103
+ setPos((p) => Math.max(0, p - 5));
104
+ } else if (e.key === 'ArrowRight') {
105
+ e.preventDefault();
106
+ setPos((p) => Math.min(100, p + 5));
107
+ }
108
+ };
109
+
110
+ if (!beforeUrl || !afterUrl) return null;
111
+
112
+ return (
113
+ <div
114
+ ref={containerRef}
115
+ className={`relative select-none overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-black/30 ${className}`}
116
+ onMouseDown={handleMouseDown}
117
+ onTouchStart={handleTouchStart}
118
+ >
119
+ {/* after 全图(底层)——决定容器尺寸 */}
120
+ <img
121
+ src={afterUrl}
122
+ alt="After"
123
+ draggable={false}
124
+ className="block max-h-full max-w-full object-contain pointer-events-none"
125
+ />
126
+
127
+ {/* before 覆盖层:绝对定位同尺寸,用 clip-path 从右侧裁剪到 pos%
128
+ (避免读取 containerRef.current.clientWidth 这种"渲染期读 ref"反模式) */}
129
+ <div
130
+ className="absolute inset-0"
131
+ style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
132
+ >
133
+ <img
134
+ src={beforeUrl}
135
+ alt="Before"
136
+ draggable={false}
137
+ className="block max-h-full max-w-full object-contain pointer-events-none"
138
+ />
139
+ </div>
140
+
141
+ {/* before/after 标签 */}
142
+ <span className="absolute top-2 left-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
143
+ Before
144
+ </span>
145
+ <span className="absolute top-2 right-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
146
+ After
147
+ </span>
148
+
149
+ {/* 分隔条 */}
150
+ <div
151
+ className="absolute top-0 bottom-0 z-10 cursor-ew-resize"
152
+ style={{ left: `${pos}%`, transform: 'translateX(-50%)' }}
153
+ role="separator"
154
+ aria-valuenow={Math.round(pos)}
155
+ aria-valuemin={0}
156
+ aria-valuemax={100}
157
+ aria-label="Comparison slider position"
158
+ tabIndex={0}
159
+ onKeyDown={handleKeyDown}
160
+ >
161
+ <div className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 bg-white shadow-md" />
162
+ <div className="absolute top-1/2 left-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-black/10">
163
+ <Icon size={14} className="text-zinc-600">
164
+ <path d="M9 5l-5 7 5 7M15 5l5 7-5 7" />
165
+ </Icon>
166
+ </div>
167
+ </div>
168
+
169
+ {/* 元数据对比(底部叠加) */}
170
+ {(beforeAsset || afterAsset) && (
171
+ <div className="absolute bottom-2 inset-x-2 flex justify-between text-[10px] text-white">
172
+ {beforeAsset?.metadata.dimensions && (
173
+ <span className="rounded bg-black/60 px-1.5 py-0.5">
174
+ {beforeAsset.metadata.dimensions.width}×{beforeAsset.metadata.dimensions.height}
175
+ </span>
176
+ )}
177
+ {afterAsset?.metadata.dimensions && (
178
+ <span className="rounded bg-black/60 px-1.5 py-0.5">
179
+ {afterAsset.metadata.dimensions.width}×{afterAsset.metadata.dimensions.height}
180
+ </span>
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }