@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
@@ -1,7 +1,32 @@
1
1
  /**
2
2
  * Workspace - 完整工作台组件
3
3
  *
4
- * 标准三栏布局:Toolbar → [AssetPanel | Canvas | Inspector] → PipelineBar → StatusBar
4
+ * W9 布局:
5
+ * ┌──────────────────────────────────────────────┐
6
+ * │ Toolbar │
7
+ * ├──────┬──────────────┬───────────────────────┤
8
+ * │Asset │ Canvas │ Inspector │
9
+ * │Panel │ (center) │ (right) │
10
+ * ├──────┴──────────────┴───────────────────────┤
11
+ * │ PipelineBar │
12
+ * ├──────────────────────────────────────────────┤
13
+ * │ HistoryPanel (horizontal, W9.1) │
14
+ * ├──────────────────────────────────────────────┤
15
+ * │ DownloadPanel (W9.5,条件渲染) │
16
+ * ├──────────────────────────────────────────────┤
17
+ * │ StatusBar │
18
+ * └──────────────────────────────────────────────┘
19
+ *
20
+ * W9 集成:
21
+ * - 9.1 HistoryPanel 移到底部(horizontal variant)
22
+ * - 9.2 CommandPalette(⌘K)挂载在 Workspace 内
23
+ * - 9.3 GlobalDropzone 全屏拖拽 + MIME 校验
24
+ * - 9.4 Canvas 内置 CompareSlider(before/after)
25
+ * - 9.5 DownloadPanel 在 HistoryPanel 下方,有 outputs 时显示
26
+ * - 9.6 StatusBar 已增强(当前工具/进度/在线)
27
+ * - 9.7 ThemeToggle 通过 Toolbar rightExtra 挂载
28
+ * - 9.8 响应式:移动端折叠为 drawer 模式
29
+ *
5
30
  * 通过 useLokvis 自动初始化 Runtime 与插件。
6
31
  *
7
32
  * @example
@@ -13,29 +38,107 @@
13
38
  * ```
14
39
  */
15
40
 
41
+ import * as React from 'react';
42
+ import { Icon } from '@lokvis/ui-core';
16
43
  import { useLokvis, type UseLokvisOptions } from '../hooks/useLokvis.js';
44
+ import { useBreakpoints } from '../hooks/useMediaQuery.js';
17
45
  import { Toolbar } from './Toolbar.js';
18
46
  import { AssetPanel } from './AssetPanel.js';
47
+ import { HistoryPanel } from './HistoryPanel.js';
19
48
  import { Canvas } from './Canvas.js';
20
49
  import { Inspector } from './Inspector.js';
21
50
  import { PipelineBar } from './PipelineBar.js';
51
+ import { WorkflowEditor } from './WorkflowEditor.js';
52
+ import { ProgressBar } from './ProgressBar.js';
53
+ import { ErrorBanner } from './ErrorBanner.js';
22
54
  import { StatusBar } from './StatusBar.js';
55
+ import { DownloadPanel } from './DownloadPanel.js';
56
+ import { CommandPalette, useCommandPalette } from './CommandPalette.js';
57
+ import { GlobalDropzone } from './GlobalDropzone.js';
58
+ import { ThemeToggle } from './ThemeToggle.js';
59
+ import { useShareLink } from '../hooks/useShareLink.js';
60
+ import { useWorkspaceStore } from '../store/index.js';
23
61
 
24
62
  export interface WorkspaceProps extends UseLokvisOptions {
25
63
  /** 顶部标题 */
26
64
  title?: string;
27
65
  /** 是否显示状态栏(默认 true) */
28
66
  showStatusBar?: boolean;
67
+ /** 是否显示历史面板(默认 true,W7.1) */
68
+ showHistoryPanel?: boolean;
69
+ /** 是否启用全屏拖拽导入(默认 true,W9.3) */
70
+ enableGlobalDropzone?: boolean;
71
+ /** 是否启用 Command Palette ⌘K(默认 true,W9.2) */
72
+ enableCommandPalette?: boolean;
73
+ /** 是否启用 ThemeToggle(默认 true,W9.7) */
74
+ enableThemeToggle?: boolean;
75
+ /** 是否启用 CompareSlider(默认 true,W9.4) */
76
+ enableCompare?: boolean;
77
+ /** 是否启用 DownloadPanel(默认 true,W9.5) */
78
+ enableDownloadPanel?: boolean;
79
+ /** 是否启用拖拽式 WorkflowEditor(默认 false,W10.4;关闭则用只读 PipelineBar) */
80
+ enableWorkflowEditor?: boolean;
81
+ /** 是否启用 ProgressBar + Cancel 按钮(默认 true,W11.6) */
82
+ enableProgressBar?: boolean;
83
+ /** 是否启用 ErrorBanner 错误信息横幅(默认 true,W11.3) */
84
+ enableErrorBanner?: boolean;
85
+ /** 是否从 URL ?workflow= 参数加载分享工作流(默认 true,W11.5) */
86
+ enableShareLink?: boolean;
29
87
  className?: string;
30
88
  }
31
89
 
90
+ /** W9.8 移动端抽屉切换状态 */
91
+ type MobilePanel = 'asset' | 'inspector' | null;
92
+
32
93
  export function Workspace({
33
94
  title,
34
95
  showStatusBar = true,
96
+ showHistoryPanel = true,
97
+ enableGlobalDropzone = true,
98
+ enableCommandPalette = true,
99
+ enableThemeToggle = true,
100
+ enableCompare = true,
101
+ enableDownloadPanel = true,
102
+ enableWorkflowEditor = false,
103
+ enableProgressBar = true,
104
+ enableErrorBanner = true,
105
+ enableShareLink = true,
35
106
  className = '',
36
107
  ...lokvisOptions
37
108
  }: WorkspaceProps) {
38
109
  const { status, error } = useLokvis(lokvisOptions);
110
+ const { isMobile } = useBreakpoints();
111
+ const [paletteOpen, setPaletteOpen] = useCommandPalette({ enabled: enableCommandPalette });
112
+ // W9.8 移动端抽屉:Asset / Inspector 切换显示
113
+ const [mobilePanel, setMobilePanel] = React.useState<MobilePanel>(null);
114
+
115
+ // W11.5: 从 URL ?workflow= 参数加载分享工作流(runtime 就绪后执行一次)
116
+ // 保护:若用户已通过其它路径(如 localStorage 恢复或手动添加)有 nodes,
117
+ // 不静默替换 —— 用 confirm 让用户显式选择,避免丢失未保存工作。
118
+ // 注:不用 useWorkspaceStore 订阅 nodes.length 做 effect 依赖
119
+ // (review 反馈:那样 nodesLength 变化会触发 effect 重跑,而 ref 已 true
120
+ // 时重跑无意义,且语义上 effect 只应在 status 切换时跑一次)。
121
+ // 改用 getState() 在体内读取最新值,既不订阅也不进 deps。
122
+ const { loadFromCurrentUrl } = useShareLink();
123
+ const shareLoadedRef = React.useRef(false);
124
+ React.useEffect(() => {
125
+ if (enableShareLink && !shareLoadedRef.current && status !== 'initializing' && status !== 'error') {
126
+ shareLoadedRef.current = true;
127
+ // 仅在 URL 含 ?workflow= 参数时才提示(避免无谓 confirm 弹窗)
128
+ const hasShareParam = typeof window !== 'undefined'
129
+ && new URLSearchParams(window.location.search).has('workflow');
130
+ if (!hasShareParam) return;
131
+ // 读取当前 nodes 长度(最新值,非订阅快照)
132
+ const currentNodesLength = useWorkspaceStore.getState().nodes.length;
133
+ if (currentNodesLength > 0) {
134
+ const ok = window.confirm(
135
+ `检测到分享工作流链接,但当前已有 ${currentNodesLength} 个节点。是否替换为分享的工作流?`
136
+ );
137
+ if (!ok) return;
138
+ }
139
+ loadFromCurrentUrl();
140
+ }
141
+ }, [enableShareLink, status, loadFromCurrentUrl]);
39
142
 
40
143
  if (status === 'initializing') {
41
144
  return (
@@ -59,7 +162,7 @@ export function Workspace({
59
162
  <div className="flex h-full items-center justify-center px-6">
60
163
  <div className="max-w-md rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-800/50 dark:bg-red-950/20">
61
164
  <div className="mb-3 flex h-10 w-10 mx-auto items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
62
- <svg className="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
165
+ <Icon size={20} className="text-red-500"><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></Icon>
63
166
  </div>
64
167
  <p className="font-semibold text-red-700 dark:text-red-400">Failed to initialize</p>
65
168
  <p className="mt-1.5 text-sm text-red-600/80 dark:text-red-400/70">{error}</p>
@@ -68,20 +171,115 @@ export function Workspace({
68
171
  );
69
172
  }
70
173
 
174
+ // W9.7 Toolbar rightExtra: ThemeToggle + ⌘K hint
175
+ const toolbarRight = (
176
+ <>
177
+ {enableCommandPalette && (
178
+ <button
179
+ type="button"
180
+ onClick={() => setPaletteOpen(true)}
181
+ aria-label="Open command palette"
182
+ title="Command palette (⌘K)"
183
+ className="flex h-7 items-center gap-1 rounded-md border border-zinc-200 px-1.5 text-[10px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
184
+ >
185
+ <Icon size={11}><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></Icon>
186
+ <kbd className="font-sans">⌘K</kbd>
187
+ </button>
188
+ )}
189
+ {enableThemeToggle && <ThemeToggle />}
190
+ {/* W9.8 移动端面板切换按钮 */}
191
+ {isMobile && (
192
+ <>
193
+ <button
194
+ type="button"
195
+ onClick={() => setMobilePanel(mobilePanel === 'asset' ? null : 'asset')}
196
+ aria-label="Toggle asset panel"
197
+ aria-pressed={mobilePanel === 'asset'}
198
+ className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
199
+ >
200
+ <Icon size={14}><rect x="3" y="3" width="7" height="18" rx="1" /><path d="M14 3h7v18h-7z" opacity="0.3" /></Icon>
201
+ </button>
202
+ <button
203
+ type="button"
204
+ onClick={() => setMobilePanel(mobilePanel === 'inspector' ? null : 'inspector')}
205
+ aria-label="Toggle inspector panel"
206
+ aria-pressed={mobilePanel === 'inspector'}
207
+ className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
208
+ >
209
+ <Icon size={14}><path d="M3 3h7v18H3z" opacity="0.3" /><rect x="14" y="3" width="7" height="18" rx="1" /></Icon>
210
+ </button>
211
+ </>
212
+ )}
213
+ </>
214
+ );
215
+
71
216
  return (
72
217
  <div className={`flex h-full flex-col bg-white dark:bg-zinc-900 ${className}`}>
73
218
  {/* Top: Toolbar */}
74
- <Toolbar title={title} />
219
+ <Toolbar title={title} rightExtra={toolbarRight} />
220
+
221
+ {/* W11.3 ErrorBanner (有 error 时显示) */}
222
+ {enableErrorBanner && <ErrorBanner />}
223
+
224
+ {/* W9.3 全屏拖拽 */}
225
+ {enableGlobalDropzone && <GlobalDropzone />}
226
+
227
+ {/* W9.2 Command Palette */}
228
+ {enableCommandPalette && (
229
+ <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
230
+ )}
75
231
 
76
- {/* Middle: Three-panel area */}
77
- <div className="flex flex-1 overflow-hidden">
78
- <AssetPanel />
79
- <Canvas />
80
- <Inspector />
232
+ {/* Middle: Panel area */}
233
+ <div className="flex flex-1 overflow-hidden relative">
234
+ {/* W9.8 桌面:三栏并列;移动:Canvas 单独,其他为 overlay drawer */}
235
+ <div
236
+ className={`${
237
+ isMobile
238
+ ? `absolute inset-y-0 left-0 z-20 w-64 max-w-[80vw] transition-transform duration-200 ${
239
+ mobilePanel === 'asset' ? 'translate-x-0' : '-translate-x-full'
240
+ }`
241
+ : 'relative'
242
+ }`}
243
+ >
244
+ <AssetPanel className="h-full" />
245
+ </div>
246
+
247
+ <Canvas enableCompare={enableCompare} />
248
+
249
+ <div
250
+ className={`${
251
+ isMobile
252
+ ? `absolute inset-y-0 right-0 z-20 w-72 max-w-[80vw] transition-transform duration-200 ${
253
+ mobilePanel === 'inspector' ? 'translate-x-0' : 'translate-x-full'
254
+ }`
255
+ : 'relative'
256
+ }`}
257
+ >
258
+ <Inspector className="h-full" />
259
+ </div>
260
+
261
+ {/* W9.8 移动端遮罩:点击关闭抽屉 */}
262
+ {isMobile && mobilePanel !== null && (
263
+ <button
264
+ type="button"
265
+ aria-label="Close panel"
266
+ onClick={() => setMobilePanel(null)}
267
+ className="absolute inset-0 z-10 bg-black/30"
268
+ />
269
+ )}
81
270
  </div>
82
271
 
83
272
  {/* Pipeline bar */}
84
- <PipelineBar />
273
+ {enableWorkflowEditor ? <WorkflowEditor /> : <PipelineBar />}
274
+
275
+ {/* W11.6 ProgressBar + Cancel */}
276
+ {enableProgressBar && <ProgressBar />}
277
+
278
+ {/* W9.1 History panel (horizontal at bottom) */}
279
+ {showHistoryPanel && <HistoryPanel variant="horizontal" />}
280
+
281
+ {/* W9.5 Download panel (条件渲染:有 outputs 时显示) */}
282
+ {enableDownloadPanel && <DownloadPanel />}
85
283
 
86
284
  {/* Bottom: StatusBar */}
87
285
  {showStatusBar && <StatusBar />}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 工作流模板数据(W11.4)
3
+ *
4
+ * 5 个内置模板,覆盖常见图片处理场景:
5
+ * 1. Web 优化 - resize + compress(webp),适用于网页素材
6
+ * 2. 社媒批量 - resize(1080) + watermark,适用于 Instagram/Twitter
7
+ * 3. 电商主图 - resize(800) + compress(jpeg) + watermark(店铺名)
8
+ * 4. 打印预处理 - resize(高分辨率) + convert(png),无损打印
9
+ * 5. 截图压缩 - resize(1280) + compress(png, 低质量),减小体积
10
+ *
11
+ * 每个模板仅含 capability + 默认 params,由 loadWorkflowTemplate() 应用到 store。
12
+ * 用户应用模板后仍可在 Inspector 调整参数。
13
+ *
14
+ * @module workflow-templates
15
+ */
16
+
17
+ /** 单个模板节点定义 */
18
+ export interface WorkflowTemplateNode {
19
+ /** 能力名,如 `image.resize` */
20
+ capability: string;
21
+ /** 默认参数(用户可修改) */
22
+ params: Record<string, unknown>;
23
+ }
24
+
25
+ /** 工作流模板 */
26
+ export interface WorkflowTemplate {
27
+ /** 模板 ID(唯一) */
28
+ id: string;
29
+ /** 模板名称(中文) */
30
+ name: string;
31
+ /** 模板描述 */
32
+ description: string;
33
+ /** 图标 emoji(用于 UI 展示) */
34
+ icon: string;
35
+ /** 模板分类 */
36
+ category: 'web' | 'social' | 'ecommerce' | 'print' | 'utility';
37
+ /** 节点序列 */
38
+ nodes: WorkflowTemplateNode[];
39
+ }
40
+
41
+ /** 5 个内置工作流模板 */
42
+ export const WORKFLOW_TEMPLATES: WorkflowTemplate[] = [
43
+ {
44
+ id: 'tpl-web-optimize',
45
+ name: 'Web 优化',
46
+ description: '调整尺寸并压缩为 WebP,适用于网页素材',
47
+ icon: '🌐',
48
+ category: 'web',
49
+ nodes: [
50
+ {
51
+ capability: 'image.resize',
52
+ params: { width: 1920, height: 1080, fit: 'inside' },
53
+ },
54
+ {
55
+ capability: 'image.compress',
56
+ params: { format: 'webp', quality: 80 },
57
+ },
58
+ ],
59
+ },
60
+ {
61
+ id: 'tpl-social-batch',
62
+ name: '社媒批量',
63
+ description: '统一为 1080px 并添加水印,适用于 Instagram/Twitter',
64
+ icon: '📱',
65
+ category: 'social',
66
+ nodes: [
67
+ {
68
+ capability: 'image.resize',
69
+ params: { width: 1080, height: 1080, fit: 'cover' },
70
+ },
71
+ {
72
+ capability: 'image.watermark',
73
+ params: { text: '@username', position: 'bottom-right', opacity: 0.7 },
74
+ },
75
+ ],
76
+ },
77
+ {
78
+ id: 'tpl-ecommerce-main',
79
+ name: '电商主图',
80
+ description: '800px 主图 + JPEG 压缩 + 店铺水印',
81
+ icon: '🛒',
82
+ category: 'ecommerce',
83
+ nodes: [
84
+ {
85
+ capability: 'image.resize',
86
+ params: { width: 800, height: 800, fit: 'contain' },
87
+ },
88
+ {
89
+ capability: 'image.compress',
90
+ params: { format: 'jpeg', quality: 90 },
91
+ },
92
+ {
93
+ capability: 'image.watermark',
94
+ params: { text: '店铺名', position: 'center', opacity: 0.3 },
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ id: 'tpl-print-prep',
100
+ name: '打印预处理',
101
+ description: '高分辨率 resize + PNG 无损转换,适用于打印',
102
+ icon: '🖨️',
103
+ category: 'print',
104
+ nodes: [
105
+ {
106
+ capability: 'image.resize',
107
+ params: { width: 3508, height: 2480, fit: 'inside', dpi: 300 },
108
+ },
109
+ {
110
+ capability: 'image.convert',
111
+ params: { format: 'png' },
112
+ },
113
+ ],
114
+ },
115
+ {
116
+ id: 'tpl-screenshot-compress',
117
+ name: '截图压缩',
118
+ description: '缩小到 1280px 并压缩为低质量 PNG,减小体积',
119
+ icon: '📸',
120
+ category: 'utility',
121
+ nodes: [
122
+ {
123
+ capability: 'image.resize',
124
+ params: { width: 1280, height: 720, fit: 'inside' },
125
+ },
126
+ {
127
+ capability: 'image.compress',
128
+ params: { format: 'png', quality: 60 },
129
+ },
130
+ ],
131
+ },
132
+ ];
133
+
134
+ /** 按 ID 查找模板 */
135
+ export function findTemplate(id: string): WorkflowTemplate | undefined {
136
+ return WORKFLOW_TEMPLATES.find((t) => t.id === id);
137
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * useDebouncedRun - 修改参数后 debounced 自动重跑(W11.2)
3
+ *
4
+ * 监听当前工作流节点序列(含 params),变化后延迟 delay 毫秒自动调用
5
+ * store.run(),实现"修改参数即时预览"。
6
+ *
7
+ * 设计要点:
8
+ * - 仅在 enabled=true 且 nodes.length>0 且 selectedAssetId 存在时触发
9
+ * - debounce 避免连续修改(如拖滑块)触发过多执行
10
+ * - 组件卸载时清除 timer,避免卸载后仍调用 store action
11
+ * - running 期间不触发新的 run(避免并发执行同一工作流;run() 也有并发保护)
12
+ * - 用户可手动触发 runNow() 立即执行(取消 pending debounce)
13
+ *
14
+ * 实现说明:
15
+ * - 主 effect 依赖 [nodesKey, enabled, selectedAssetId, delay](用户输入)
16
+ * - run / running / nodes.length 通过 ref 读取最新值,避免加入 deps:
17
+ * - run / running:store action 引用每次 store 变化都变,加入 deps 触发循环重订阅
18
+ * - nodes.length:节点数量变化必然使 nodesKey(JSON 序列化)变化,
19
+ * 加入 deps 是冗余触发源(节点增减会触发两次 effect 重新计时)
20
+ * - 这是 React 社区标准模式(避免 effect 与 store action 循环 + 消除冗余触发),非 workaround
21
+ *
22
+ * 典型用法:
23
+ * ```tsx
24
+ * const { enabled, setEnabled, runNow } = useDebouncedRun({ delay: 400 });
25
+ * <Toggle checked={enabled} onChange={setEnabled} label="实时预览" />
26
+ * ```
27
+ *
28
+ * @module useDebouncedRun
29
+ */
30
+
31
+ import { useCallback, useEffect, useRef, useState } from 'react';
32
+ import { useWorkspaceStore } from '../store/index.js';
33
+
34
+ export interface UseDebouncedRunOptions {
35
+ /** 防抖延迟毫秒(默认 400ms,平衡响应性与性能) */
36
+ delay?: number;
37
+ /** 初始是否启用(默认 false,需用户显式开启避免意外执行) */
38
+ initialEnabled?: boolean;
39
+ }
40
+
41
+ export interface UseDebouncedRunResult {
42
+ /** 当前是否启用自动运行 */
43
+ enabled: boolean;
44
+ /** 切换启用状态 */
45
+ setEnabled: (v: boolean) => void;
46
+ /** 立即触发运行(取消 pending debounce) */
47
+ runNow: () => void;
48
+ /** 取消 pending debounce(不取消正在运行的) */
49
+ cancelPending: () => void;
50
+ /** 是否有 pending 的执行(等待 debounce 触发) */
51
+ isPending: boolean;
52
+ }
53
+
54
+ /** 序列化节点序列用于变化检测(params 嵌套对象需稳定序列化) */
55
+ function serializeNodes(
56
+ nodes: ReadonlyArray<{ capability: string; params: Record<string, unknown> }>
57
+ ): string {
58
+ return JSON.stringify(
59
+ nodes.map((n) => ({ c: n.capability, p: n.params }))
60
+ );
61
+ }
62
+
63
+ export function useDebouncedRun(
64
+ options: UseDebouncedRunOptions = {}
65
+ ): UseDebouncedRunResult {
66
+ const { delay = 400, initialEnabled = false } = options;
67
+ const [enabled, setEnabled] = useState(initialEnabled);
68
+ const [isPending, setIsPending] = useState(false);
69
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+
71
+ const nodes = useWorkspaceStore((s) => s.nodes);
72
+ const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
73
+ const running = useWorkspaceStore((s) => s.running);
74
+ const run = useWorkspaceStore((s) => s.run);
75
+
76
+ // 用 ref 持有最新的 run / running / nodes.length 引用:
77
+ // 主 effect 依赖数组只跟踪用户输入(nodesKey / enabled / selectedAssetId / delay),
78
+ // 不包含 store action 引用(每次 store 变化都生成新函数引用,加入 deps 会
79
+ // 触发 effect 重订阅 → debounce timer 重置 → 无法稳定等待 delay)。
80
+ // nodes.length 同样用 ref 读取:节点数量变化必然导致 nodesKey(JSON 序列化)
81
+ // 变化,nodes.length 在主 effect deps 中是冗余触发源,改用 ref 在 body 中读取
82
+ // 最新值即可(与 run / running 同模式,非 workaround)。
83
+ const runRef = useRef(run);
84
+ const runningRef = useRef(running);
85
+ const nodesLengthRef = useRef(nodes.length);
86
+ useEffect(() => {
87
+ runRef.current = run;
88
+ runningRef.current = running;
89
+ nodesLengthRef.current = nodes.length;
90
+ }, [run, running, nodes.length]);
91
+
92
+ // 序列化节点,作为 debounce 触发依赖
93
+ const nodesKey = serializeNodes(nodes);
94
+
95
+ const clearTimer = useCallback(() => {
96
+ if (timerRef.current !== null) {
97
+ clearTimeout(timerRef.current);
98
+ timerRef.current = null;
99
+ }
100
+ setIsPending(false);
101
+ }, []);
102
+
103
+ const runNow = useCallback(() => {
104
+ clearTimer();
105
+ if (!selectedAssetId || nodesLengthRef.current === 0 || runningRef.current) return;
106
+ void runRef.current();
107
+ }, [clearTimer, selectedAssetId]);
108
+
109
+ // 监听节点序列变化。依赖数组为用户输入:
110
+ // nodesKey(序列化节点) / enabled / selectedAssetId / delay
111
+ // run / running / nodes.length 通过 ref 读取最新值(见上):
112
+ // - run / running:action 引用每次 store 变化都变,加入 deps 触发循环重订阅
113
+ // - nodes.length:节点数量变化必然使 nodesKey 变化,加入 deps 是冗余触发源
114
+ useEffect(() => {
115
+ if (!enabled) return;
116
+ if (!selectedAssetId || nodesLengthRef.current === 0 || runningRef.current) return;
117
+
118
+ // 清除上一次 pending,重新计时
119
+ if (timerRef.current !== null) {
120
+ clearTimeout(timerRef.current);
121
+ }
122
+ setIsPending(true);
123
+ timerRef.current = setTimeout(() => {
124
+ timerRef.current = null;
125
+ setIsPending(false);
126
+ // 再次检查 running + 节点非空,避免 timer 排队期间状态已变化
127
+ // (用户手动触发 run / 清空节点)
128
+ if (!runningRef.current && nodesLengthRef.current > 0) {
129
+ void runRef.current();
130
+ }
131
+ }, delay);
132
+
133
+ return () => {
134
+ // cleanup:清 timer + 重置 isPending。React 18 卸载后调用 setState 不再 warn,
135
+ // 但 cleanup 仍是正确做法(避免卸载后 timer 触发仍调用 store action)。
136
+ if (timerRef.current !== null) {
137
+ clearTimeout(timerRef.current);
138
+ timerRef.current = null;
139
+ }
140
+ setIsPending(false);
141
+ };
142
+ }, [nodesKey, enabled, selectedAssetId, delay]);
143
+
144
+ return {
145
+ enabled,
146
+ setEnabled,
147
+ runNow,
148
+ cancelPending: clearTimer,
149
+ isPending,
150
+ };
151
+ }
@@ -71,7 +71,7 @@ export function useLokvis(options: UseLokvisOptions = {}): UseLokvisResult {
71
71
  return () => {
72
72
  cancelled = true;
73
73
  };
74
- // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
75
75
  }, []);
76
76
 
77
77
  return { runtime, status, error };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * useMediaQuery - 响应式断点 hook(W9.8)
3
+ *
4
+ * 在 React 中订阅 `matchMedia` 查询,断点变化时触发组件重渲染。
5
+ * SSR 安全:`window` 不存在时返回初始值 `false`。
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const isMobile = useMediaQuery('(max-width: 768px)');
10
+ * const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
11
+ * ```
12
+ *
13
+ * 内置断点常量(与 Tailwind v4 默认对齐):
14
+ * - sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px
15
+ */
16
+
17
+ import { useEffect, useState } from 'react';
18
+
19
+ export function useMediaQuery(query: string): boolean {
20
+ const [matches, setMatches] = useState<boolean>(() => {
21
+ if (typeof window === 'undefined' || !window.matchMedia) return false;
22
+ return window.matchMedia(query).matches;
23
+ });
24
+
25
+ useEffect(() => {
26
+ if (typeof window === 'undefined' || !window.matchMedia) return;
27
+ const mq = window.matchMedia(query);
28
+ const onChange = () => setMatches(mq.matches);
29
+ // 初始同步(matchMedia 可能在 effect 跑前已变化)
30
+ setMatches(mq.matches);
31
+ mq.addEventListener('change', onChange);
32
+ return () => mq.removeEventListener('change', onChange);
33
+ }, [query]);
34
+
35
+ return matches;
36
+ }
37
+
38
+ /** W9.8 三档断点 helper */
39
+ export interface Breakpoints {
40
+ /** 移动端 (< 768px) */
41
+ isMobile: boolean;
42
+ /** 平板 (768px - 1023px) */
43
+ isTablet: boolean;
44
+ /** 桌面 (>= 1024px) */
45
+ isDesktop: boolean;
46
+ }
47
+
48
+ export function useBreakpoints(): Breakpoints {
49
+ const isMobile = useMediaQuery('(max-width: 767px)');
50
+ const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
51
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
52
+ return { isMobile, isTablet, isDesktop };
53
+ }