@lokvis/ui-react 0.2.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +10 -54
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts.map +1 -1
  5. package/dist/components/Canvas.js +4 -4
  6. package/dist/components/Canvas.js.map +1 -1
  7. package/dist/components/CompareSlider.d.ts +4 -3
  8. package/dist/components/CompareSlider.d.ts.map +1 -1
  9. package/dist/components/CompareSlider.js +5 -4
  10. package/dist/components/CompareSlider.js.map +1 -1
  11. package/dist/components/DownloadPanel.d.ts.map +1 -1
  12. package/dist/components/DownloadPanel.js +1 -8
  13. package/dist/components/DownloadPanel.js.map +1 -1
  14. package/dist/components/ErrorBoundary.d.ts +40 -0
  15. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  16. package/dist/components/ErrorBoundary.js +52 -0
  17. package/dist/components/ErrorBoundary.js.map +1 -0
  18. package/dist/components/ExifPanel.js +1 -1
  19. package/dist/components/ExifPanel.js.map +1 -1
  20. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  21. package/dist/components/GlobalDropzone.js +10 -3
  22. package/dist/components/GlobalDropzone.js.map +1 -1
  23. package/dist/components/Inspector.d.ts.map +1 -1
  24. package/dist/components/Inspector.js +10 -7
  25. package/dist/components/Inspector.js.map +1 -1
  26. package/dist/components/ParamForm.d.ts.map +1 -1
  27. package/dist/components/ParamForm.js +8 -5
  28. package/dist/components/ParamForm.js.map +1 -1
  29. package/dist/components/PipelineBar.d.ts.map +1 -1
  30. package/dist/components/PipelineBar.js +17 -26
  31. package/dist/components/PipelineBar.js.map +1 -1
  32. package/dist/components/StatusBar.d.ts.map +1 -1
  33. package/dist/components/StatusBar.js +3 -12
  34. package/dist/components/StatusBar.js.map +1 -1
  35. package/dist/components/StatusDot.d.ts +12 -0
  36. package/dist/components/StatusDot.d.ts.map +1 -0
  37. package/dist/components/StatusDot.js +15 -0
  38. package/dist/components/StatusDot.js.map +1 -0
  39. package/dist/components/ThemeToggle.js +1 -1
  40. package/dist/components/ThemeToggle.js.map +1 -1
  41. package/dist/components/ToolRunner.d.ts +53 -0
  42. package/dist/components/ToolRunner.d.ts.map +1 -0
  43. package/dist/components/ToolRunner.js +142 -0
  44. package/dist/components/ToolRunner.js.map +1 -0
  45. package/dist/components/Toolbar.js +2 -2
  46. package/dist/components/Toolbar.js.map +1 -1
  47. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  48. package/dist/components/WorkflowEditor.js +31 -40
  49. package/dist/components/WorkflowEditor.js.map +1 -1
  50. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  51. package/dist/components/WorkflowTemplates.js +15 -5
  52. package/dist/components/WorkflowTemplates.js.map +1 -1
  53. package/dist/components/Workspace.d.ts +23 -1
  54. package/dist/components/Workspace.d.ts.map +1 -1
  55. package/dist/components/Workspace.js +60 -13
  56. package/dist/components/Workspace.js.map +1 -1
  57. package/dist/hooks/useCustomPresets.d.ts +98 -0
  58. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  59. package/dist/hooks/useCustomPresets.js +210 -0
  60. package/dist/hooks/useCustomPresets.js.map +1 -0
  61. package/dist/hooks/useLokvis.d.ts +19 -1
  62. package/dist/hooks/useLokvis.d.ts.map +1 -1
  63. package/dist/hooks/useLokvis.js +30 -9
  64. package/dist/hooks/useLokvis.js.map +1 -1
  65. package/dist/index.d.ts +3 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +3 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/store/assets-slice.d.ts +8 -1
  70. package/dist/store/assets-slice.d.ts.map +1 -1
  71. package/dist/store/assets-slice.js +101 -54
  72. package/dist/store/assets-slice.js.map +1 -1
  73. package/dist/store/capabilities-slice.d.ts +1 -1
  74. package/dist/store/capabilities-slice.d.ts.map +1 -1
  75. package/dist/store/capabilities-slice.js +8 -1
  76. package/dist/store/capabilities-slice.js.map +1 -1
  77. package/dist/store/index.d.ts +1 -1
  78. package/dist/store/index.d.ts.map +1 -1
  79. package/dist/store/index.js +1 -1
  80. package/dist/store/index.js.map +1 -1
  81. package/dist/store/runtime-slice.d.ts.map +1 -1
  82. package/dist/store/runtime-slice.js +22 -16
  83. package/dist/store/runtime-slice.js.map +1 -1
  84. package/dist/store/subscribe-utils.d.ts +45 -0
  85. package/dist/store/subscribe-utils.d.ts.map +1 -0
  86. package/dist/store/subscribe-utils.js +69 -0
  87. package/dist/store/subscribe-utils.js.map +1 -0
  88. package/dist/store/types.d.ts +19 -11
  89. package/dist/store/types.d.ts.map +1 -1
  90. package/dist/store/types.js +0 -10
  91. package/dist/store/types.js.map +1 -1
  92. package/dist/store/workflow-slice.d.ts +1 -1
  93. package/dist/store/workflow-slice.d.ts.map +1 -1
  94. package/dist/store/workflow-slice.js +19 -51
  95. package/dist/store/workflow-slice.js.map +1 -1
  96. package/dist/utils.d.ts +24 -0
  97. package/dist/utils.d.ts.map +1 -0
  98. package/dist/utils.js +31 -0
  99. package/dist/utils.js.map +1 -0
  100. package/package.json +7 -6
  101. package/src/components/AssetPanel.tsx +18 -58
  102. package/src/components/Canvas.tsx +11 -7
  103. package/src/components/CompareSlider.tsx +13 -10
  104. package/src/components/DownloadPanel.tsx +1 -7
  105. package/src/components/ErrorBoundary.tsx +101 -0
  106. package/src/components/ExifPanel.tsx +1 -1
  107. package/src/components/GlobalDropzone.tsx +8 -3
  108. package/src/components/Inspector.tsx +24 -16
  109. package/src/components/ParamForm.tsx +12 -9
  110. package/src/components/PipelineBar.tsx +20 -27
  111. package/src/components/StatusBar.tsx +3 -10
  112. package/src/components/StatusDot.tsx +27 -0
  113. package/src/components/ThemeToggle.tsx +1 -1
  114. package/src/components/ToolRunner.tsx +352 -0
  115. package/src/components/Toolbar.tsx +2 -2
  116. package/src/components/WorkflowEditor.tsx +50 -43
  117. package/src/components/WorkflowTemplates.tsx +27 -4
  118. package/src/components/Workspace.tsx +97 -10
  119. package/src/hooks/useCustomPresets.ts +284 -0
  120. package/src/hooks/useLokvis.ts +43 -10
  121. package/src/index.ts +3 -0
  122. package/src/store/assets-slice.ts +106 -52
  123. package/src/store/capabilities-slice.ts +10 -2
  124. package/src/store/index.ts +1 -1
  125. package/src/store/runtime-slice.ts +20 -15
  126. package/src/store/subscribe-utils.ts +82 -0
  127. package/src/store/types.ts +19 -12
  128. package/src/store/workflow-slice.ts +19 -53
  129. package/src/utils.ts +41 -0
  130. package/dist/store.d.ts +0 -76
  131. package/dist/store.d.ts.map +0 -1
  132. package/dist/store.js +0 -217
  133. package/dist/store.js.map +0 -1
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  import * as React from 'react';
42
- import { Icon } from '@lokvis/ui-core';
42
+ import { ConfirmDialog, Icon } from '@lokvis/ui-core';
43
43
  import { useLokvis, type UseLokvisOptions } from '../hooks/useLokvis.js';
44
44
  import { useBreakpoints } from '../hooks/useMediaQuery.js';
45
45
  import { Toolbar } from './Toolbar.js';
@@ -56,6 +56,7 @@ import { DownloadPanel } from './DownloadPanel.js';
56
56
  import { CommandPalette, useCommandPalette } from './CommandPalette.js';
57
57
  import { GlobalDropzone } from './GlobalDropzone.js';
58
58
  import { ThemeToggle } from './ThemeToggle.js';
59
+ import { ErrorBoundary } from './ErrorBoundary.js';
59
60
  import { useShareLink } from '../hooks/useShareLink.js';
60
61
  import { useWorkspaceStore } from '../store/index.js';
61
62
 
@@ -84,6 +85,27 @@ export interface WorkspaceProps extends UseLokvisOptions {
84
85
  enableErrorBanner?: boolean;
85
86
  /** 是否从 URL ?workflow= 参数加载分享工作流(默认 true,W11.5) */
86
87
  enableShareLink?: boolean;
88
+
89
+ /**
90
+ * 初始化时自动导入的资产列表。
91
+ * Runtime 就绪后,通过 runtime.importAsset() 逐个导入。
92
+ */
93
+ initialAssets?: Array<{ blob: Blob; name: string }>;
94
+ /**
95
+ * 初始化时预选的能力名称(自动添加到工作流节点)。
96
+ */
97
+ initialCapability?: string;
98
+ /**
99
+ * 初始化时预填的参数(与 initialCapability 配合使用)。
100
+ */
101
+ initialParams?: Record<string, unknown>;
102
+ /**
103
+ * 布局模式:
104
+ * - 'full': 完整三栏布局(默认)
105
+ * - 'focused': 精简模式,隐藏 AssetPanel 和 PipelineBar,适合单工具页跳转进入
106
+ */
107
+ mode?: 'full' | 'focused';
108
+
87
109
  className?: string;
88
110
  }
89
111
 
@@ -103,6 +125,10 @@ export function Workspace({
103
125
  enableProgressBar = true,
104
126
  enableErrorBanner = true,
105
127
  enableShareLink = true,
128
+ initialAssets,
129
+ initialCapability,
130
+ initialParams,
131
+ mode = 'full',
106
132
  className = '',
107
133
  ...lokvisOptions
108
134
  }: WorkspaceProps) {
@@ -114,39 +140,80 @@ export function Workspace({
114
140
 
115
141
  // W11.5: 从 URL ?workflow= 参数加载分享工作流(runtime 就绪后执行一次)
116
142
  // 保护:若用户已通过其它路径(如 localStorage 恢复或手动添加)有 nodes,
117
- // 不静默替换 —— 用 confirm 让用户显式选择,避免丢失未保存工作。
143
+ // 不静默替换 —— 用 ConfirmDialog 让用户显式选择,避免丢失未保存工作。
118
144
  // 注:不用 useWorkspaceStore 订阅 nodes.length 做 effect 依赖
119
145
  // (review 反馈:那样 nodesLength 变化会触发 effect 重跑,而 ref 已 true
120
146
  // 时重跑无意义,且语义上 effect 只应在 status 切换时跑一次)。
121
147
  // 改用 getState() 在体内读取最新值,既不订阅也不进 deps。
122
148
  const { loadFromCurrentUrl } = useShareLink();
123
149
  const shareLoadedRef = React.useRef(false);
150
+ // 待确认的分享链接替换(nodes>0 时需用户确认才替换)
151
+ const [shareConfirmOpen, setShareConfirmOpen] = React.useState(false);
152
+ const [shareConfirmNodes, setShareConfirmNodes] = React.useState(0);
124
153
  React.useEffect(() => {
125
154
  if (enableShareLink && !shareLoadedRef.current && status !== 'initializing' && status !== 'error') {
126
155
  shareLoadedRef.current = true;
127
- // 仅在 URL 含 ?workflow= 参数时才提示(避免无谓 confirm 弹窗)
156
+ // 仅在 URL 含 ?workflow= 参数时才提示(避免无谓弹窗)
128
157
  const hasShareParam = typeof window !== 'undefined'
129
158
  && new URLSearchParams(window.location.search).has('workflow');
130
159
  if (!hasShareParam) return;
131
160
  // 读取当前 nodes 长度(最新值,非订阅快照)
132
161
  const currentNodesLength = useWorkspaceStore.getState().nodes.length;
133
162
  if (currentNodesLength > 0) {
134
- const ok = window.confirm(
135
- `检测到分享工作流链接,但当前已有 ${currentNodesLength} 个节点。是否替换为分享的工作流?`
136
- );
137
- if (!ok) return;
163
+ setShareConfirmNodes(currentNodesLength);
164
+ setShareConfirmOpen(true);
165
+ return;
138
166
  }
139
167
  loadFromCurrentUrl();
140
168
  }
141
169
  }, [enableShareLink, status, loadFromCurrentUrl]);
142
170
 
171
+ // 初始化 props:runtime 就绪后导入资产、添加节点、填充参数
172
+ const initDoneRef = React.useRef(false);
173
+ React.useEffect(() => {
174
+ if (initDoneRef.current || status !== 'ready') return;
175
+ const store = useWorkspaceStore.getState();
176
+ if (!store.runtime) return;
177
+ initDoneRef.current = true;
178
+
179
+ (async () => {
180
+ // 导入初始资产
181
+ if (initialAssets?.length) {
182
+ for (const { blob, name } of initialAssets) {
183
+ const file = new File([blob], name, { type: blob.type });
184
+ await store.runtime!.importAsset({ kind: 'file', file });
185
+ }
186
+ await store.refreshAssets();
187
+ // 自动选中第一个资产
188
+ const assets = store.assets;
189
+ if (assets.length > 0) {
190
+ store.selectAsset(assets[0]!.id);
191
+ }
192
+ }
193
+
194
+ // 添加初始能力节点并填充参数
195
+ if (initialCapability) {
196
+ store.addNode(initialCapability);
197
+ if (initialParams) {
198
+ const nodes = useWorkspaceStore.getState().nodes;
199
+ const lastNode = nodes[nodes.length - 1];
200
+ if (lastNode) {
201
+ store.updateNodeParams(lastNode.id, initialParams);
202
+ }
203
+ }
204
+ }
205
+ })();
206
+ }, [status, initialAssets, initialCapability, initialParams]);
207
+
208
+ const isFocused = mode === 'focused';
209
+
143
210
  if (status === 'initializing') {
144
211
  return (
145
212
  <div className="flex h-full items-center justify-center">
146
213
  <div className="flex flex-col items-center gap-4">
147
214
  <div className="relative flex h-12 w-12 items-center justify-center">
148
215
  <div className="absolute inset-0 rounded-full border-2 border-[var(--lokvis-primary)]/40" />
149
- <div className="h-8 w-8 animate-spin rounded-full border-2 border-transparent border-t-indigo-500" />
216
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-transparent border-t-[var(--lokvis-primary)]" />
150
217
  </div>
151
218
  <div className="text-center">
152
219
  <p className="text-sm font-medium text-[var(--lokvis-fg-muted)]">Initializing Runtime</p>
@@ -214,6 +281,9 @@ export function Workspace({
214
281
  );
215
282
 
216
283
  return (
284
+ // D6: ErrorBoundary 捕获子组件渲染异常,避免整个 workspace 白屏。
285
+ // useLokvis 异常不在本组件树内(早于本 return),由消费方在外层包裹处理。
286
+ <ErrorBoundary>
217
287
  <div className={`flex h-full flex-col bg-[var(--lokvis-surface)] ${className}`}>
218
288
  {/* Top: Toolbar */}
219
289
  <Toolbar title={title} rightExtra={toolbarRight} />
@@ -232,6 +302,7 @@ export function Workspace({
232
302
  {/* Middle: Panel area */}
233
303
  <div className="flex flex-1 overflow-hidden relative">
234
304
  {/* W9.8 桌面:三栏并列;移动:Canvas 单独,其他为 overlay drawer */}
305
+ {!isFocused && (
235
306
  <div
236
307
  className={`${
237
308
  isMobile
@@ -243,6 +314,7 @@ export function Workspace({
243
314
  >
244
315
  <AssetPanel className="h-full" />
245
316
  </div>
317
+ )}
246
318
 
247
319
  <Canvas enableCompare={enableCompare} />
248
320
 
@@ -259,7 +331,7 @@ export function Workspace({
259
331
  </div>
260
332
 
261
333
  {/* W9.8 移动端遮罩:点击关闭抽屉 */}
262
- {isMobile && mobilePanel !== null && (
334
+ {isMobile && mobilePanel !== null && !isFocused && (
263
335
  <button
264
336
  type="button"
265
337
  aria-label="Close panel"
@@ -270,7 +342,7 @@ export function Workspace({
270
342
  </div>
271
343
 
272
344
  {/* Pipeline bar */}
273
- {enableWorkflowEditor ? <WorkflowEditor /> : <PipelineBar />}
345
+ {!isFocused && (enableWorkflowEditor ? <WorkflowEditor /> : <PipelineBar />)}
274
346
 
275
347
  {/* W11.6 ProgressBar + Cancel */}
276
348
  {enableProgressBar && <ProgressBar />}
@@ -283,6 +355,21 @@ export function Workspace({
283
355
 
284
356
  {/* Bottom: StatusBar */}
285
357
  {showStatusBar && <StatusBar />}
358
+
359
+ {/* W11.5 分享链接替换确认(替代 window.confirm) */}
360
+ <ConfirmDialog
361
+ open={shareConfirmOpen}
362
+ title="加载分享工作流"
363
+ message={`检测到分享工作流链接,但当前已有 ${shareConfirmNodes} 个节点。是否替换为分享的工作流?`}
364
+ confirmText="替换"
365
+ variant="danger"
366
+ onConfirm={() => {
367
+ setShareConfirmOpen(false);
368
+ loadFromCurrentUrl();
369
+ }}
370
+ onClose={() => setShareConfirmOpen(false)}
371
+ />
286
372
  </div>
373
+ </ErrorBoundary>
287
374
  );
288
375
  }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * useCustomPresets - 自定义尺寸预设持久化 hook(W17.5)
3
+ *
4
+ * 提供用户自定义图像尺寸预设的本地保存能力(免费 3 / Pro 无限)。
5
+ * 数据存储在 localStorage 的 `lokvis.customPresets` key,JSON 数组形式。
6
+ *
7
+ * 与 useWorkflows 的区别:
8
+ * - 自定义预设是尺寸配置(name/width/height/fit/format),轻量
9
+ * - 工作流槽位是完整 Workflow 定义(含 nodes/edges/inputs/outputs)
10
+ *
11
+ * 与 PLATFORM_PRESETS 的区别:
12
+ * - PLATFORM_PRESETS 是内置的固定预设(YouTube/TikTok 等平台标准尺寸)
13
+ * - 自定义预设是用户自建的任意尺寸配置,自由命名
14
+ *
15
+ * id 命名空间约定:自定义预设 id 以 `custom.` 前缀,与内置预设区分
16
+ * (如 `custom.my-instagram-square`),避免 PlatformPresetSelector 冲突。
17
+ *
18
+ * 多 tab / 多实例同步:
19
+ * - 原生 storage 事件:跨 tab 同步
20
+ * - 自定义 SYNC_EVENT:同 tab 多实例同步
21
+ *
22
+ * @module useCustomPresets
23
+ */
24
+
25
+ import { useCallback, useEffect, useState } from 'react';
26
+ import type {
27
+ PlatformFitStrategy,
28
+ PlatformRecommendedFormat,
29
+ } from '@lokvis/capability';
30
+
31
+ const STORAGE_KEY = 'lokvis.customPresets';
32
+ /** 同 tab 多实例同步用的自定义事件名 */
33
+ const SYNC_EVENT = 'lokvis:custom-presets-change';
34
+
35
+ /** 免费用户自定义预设上限 */
36
+ export const FREE_PRESET_LIMIT = 3;
37
+ /** Pro 用户无上限 */
38
+ export const PRO_PRESET_LIMIT = Infinity;
39
+
40
+ /** 自定义尺寸预设存储格式 */
41
+ export interface CustomSizePreset {
42
+ /** 唯一 ID,以 `custom.` 前缀 */
43
+ id: string;
44
+ /** 用户自定义名称(如 "我的方形预设") */
45
+ name: string;
46
+ /** 目标宽度(像素) */
47
+ width: number;
48
+ /** 目标高度(像素) */
49
+ height: number;
50
+ /** fit 策略(默认 cover) */
51
+ fit: PlatformFitStrategy;
52
+ /** 推荐输出格式(可选) */
53
+ format?: PlatformRecommendedFormat;
54
+ /** 创建时间戳 */
55
+ createdAt: number;
56
+ /** 最后更新时间戳 */
57
+ updatedAt: number;
58
+ }
59
+
60
+ /** 保存预设时的输入(不含 id / 时间戳,由 save 自动生成) */
61
+ export interface SavePresetInput {
62
+ /** 预设名称 */
63
+ name: string;
64
+ /** 目标宽度(像素,>0) */
65
+ width: number;
66
+ /** 目标高度(像素,>0) */
67
+ height: number;
68
+ /** fit 策略(默认 cover) */
69
+ fit?: PlatformFitStrategy;
70
+ /** 推荐输出格式(可选) */
71
+ format?: PlatformRecommendedFormat;
72
+ /** 若指定则更新该 id 的预设,否则新增 */
73
+ id?: string;
74
+ }
75
+
76
+ export interface UseCustomPresetsResult {
77
+ /** 当前所有自定义预设(按 updatedAt 降序) */
78
+ presets: CustomSizePreset[];
79
+ /** 保存或更新预设;超限时抛 Error */
80
+ save(input: SavePresetInput): CustomSizePreset;
81
+ /** 按 id 删除预设 */
82
+ remove(id: string): void;
83
+ /** 按 id 查找预设 */
84
+ get(id: string): CustomSizePreset | null;
85
+ /** 当前上限(免费 3 / Pro 无限) */
86
+ limit: number;
87
+ /** 是否还能再保存 */
88
+ canSaveMore: boolean;
89
+ /** 还能再保存多少个 */
90
+ remaining: number;
91
+ }
92
+
93
+ /** 类型守卫:判断 unknown 是否为 Record<string, unknown> */
94
+ function isRecord(x: unknown): x is Record<string, unknown> {
95
+ return typeof x === 'object' && x !== null;
96
+ }
97
+
98
+ const VALID_FIT_STRATEGIES: readonly PlatformFitStrategy[] = ['cover', 'contain', 'fill', 'inside', 'outside'];
99
+ const VALID_FORMATS: readonly PlatformRecommendedFormat[] = ['png', 'jpeg', 'webp'];
100
+
101
+ /** 从 localStorage 读取自定义预设。
102
+ * 容错:JSON 解析失败 / 非数组 / 字段缺失时返回空数组。
103
+ *
104
+ * 导出供测试直接调用(避免渲染 React 组件)。 */
105
+ export function readCustomPresetsFromStorage(): CustomSizePreset[] {
106
+ if (typeof window === 'undefined') return [];
107
+ try {
108
+ const raw = window.localStorage.getItem(STORAGE_KEY);
109
+ if (!raw) return [];
110
+ const parsed: unknown = JSON.parse(raw);
111
+ if (!Array.isArray(parsed)) return [];
112
+ // 字段白名单过滤(防御损坏数据)
113
+ const result = parsed.filter((it): it is CustomSizePreset => {
114
+ if (!isRecord(it)) return false;
115
+ if (
116
+ typeof it.id !== 'string' ||
117
+ typeof it.name !== 'string' ||
118
+ typeof it.width !== 'number' ||
119
+ typeof it.height !== 'number' ||
120
+ typeof it.fit !== 'string' ||
121
+ typeof it.createdAt !== 'number' ||
122
+ typeof it.updatedAt !== 'number'
123
+ ) {
124
+ return false;
125
+ }
126
+ // id 必须以 custom. 前缀开头,与内置 PLATFORM_PRESETS 隔离
127
+ if (!it.id.startsWith('custom.')) return false;
128
+ // fit 必须是合法值
129
+ if (!VALID_FIT_STRATEGIES.includes(it.fit as PlatformFitStrategy)) {
130
+ return false;
131
+ }
132
+ // format 可选,若有则必须是合法值
133
+ if (it.format !== undefined && !VALID_FORMATS.includes(it.format as PlatformRecommendedFormat)) {
134
+ return false;
135
+ }
136
+ // width / height 必须为正整数
137
+ if (it.width <= 0 || it.height <= 0) return false;
138
+ return true;
139
+ });
140
+ // 按 updatedAt 降序(最新在前),与接口契约一致
141
+ result.sort((a, b) => b.updatedAt - a.updatedAt);
142
+ return result;
143
+ } catch {
144
+ console.warn('[lokvis] Failed to read custom presets from storage');
145
+ return [];
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 写入 localStorage。
151
+ * @returns true 成功;false 失败(隐私模式 / 配额超限 / 序列化异常)
152
+ *
153
+ * 导出供测试直接调用。
154
+ */
155
+ export function writeCustomPresetsToStorage(presets: CustomSizePreset[]): boolean {
156
+ if (typeof window === 'undefined') return false;
157
+ try {
158
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
159
+ window.dispatchEvent(new CustomEvent(SYNC_EVENT));
160
+ return true;
161
+ } catch (err) {
162
+ console.warn('[lokvis] Failed to persist custom presets to localStorage:', err);
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /** 生成唯一 ID(custom. 前缀,与内置预设命名空间隔离) */
168
+ export function genCustomPresetId(): string {
169
+ const rand = Math.random().toString(36).slice(2, 8);
170
+ return `custom.${Date.now()}-${rand}`;
171
+ }
172
+
173
+ /**
174
+ * @param isPro 是否为 Pro 模式。父组件从 runtime.isPro 传入。
175
+ * 影响 limit:免费 3 / Pro 无上限。
176
+ */
177
+ export function useCustomPresets(isPro = false): UseCustomPresetsResult {
178
+ const limit = isPro ? PRO_PRESET_LIMIT : FREE_PRESET_LIMIT;
179
+ const [presets, setPresets] = useState<CustomSizePreset[]>(() =>
180
+ readCustomPresetsFromStorage()
181
+ );
182
+
183
+ // 监听跨 tab storage + 同 tab SYNC_EVENT
184
+ useEffect(() => {
185
+ const handler = () => setPresets(readCustomPresetsFromStorage());
186
+ window.addEventListener('storage', handler);
187
+ window.addEventListener(SYNC_EVENT, handler);
188
+ return () => {
189
+ window.removeEventListener('storage', handler);
190
+ window.removeEventListener(SYNC_EVENT, handler);
191
+ };
192
+ }, []);
193
+
194
+ const save = useCallback(
195
+ (input: SavePresetInput): CustomSizePreset => {
196
+ // 输入校验
197
+ if (input.width <= 0 || input.height <= 0) {
198
+ throw new Error('预设尺寸必须为正数');
199
+ }
200
+ if (!input.name.trim()) {
201
+ throw new Error('预设名称不能为空');
202
+ }
203
+
204
+ const current = readCustomPresetsFromStorage();
205
+ const now = Date.now();
206
+
207
+ // 更新已有
208
+ if (input.id) {
209
+ const existing = current.find((p) => p.id === input.id);
210
+ if (existing) {
211
+ const updated: CustomSizePreset = {
212
+ ...existing,
213
+ name: input.name,
214
+ width: input.width,
215
+ height: input.height,
216
+ fit: input.fit ?? existing.fit,
217
+ format: input.format ?? existing.format,
218
+ updatedAt: now,
219
+ };
220
+ const next = current.map((p) => (p.id === input.id ? updated : p));
221
+ const ok = writeCustomPresetsToStorage(next);
222
+ if (!ok) {
223
+ console.warn('[lokvis] 自定义预设未持久化到 localStorage,仅当前会话有效');
224
+ }
225
+ setPresets(next);
226
+ return updated;
227
+ }
228
+ }
229
+
230
+ // 新增
231
+ if (current.length >= limit) {
232
+ throw new Error(
233
+ `自定义预设已达上限(${limit} 个)${isPro ? '' : ',升级 Pro 可无限制保存'}`
234
+ );
235
+ }
236
+ const preset: CustomSizePreset = {
237
+ id: genCustomPresetId(),
238
+ name: input.name,
239
+ width: input.width,
240
+ height: input.height,
241
+ fit: input.fit ?? 'cover',
242
+ format: input.format,
243
+ createdAt: now,
244
+ updatedAt: now,
245
+ };
246
+ const next = [...current, preset];
247
+ const ok = writeCustomPresetsToStorage(next);
248
+ if (!ok) {
249
+ console.warn('[lokvis] 自定义预设未持久化到 localStorage,仅当前会话有效');
250
+ }
251
+ setPresets(next);
252
+ return preset;
253
+ },
254
+ [limit, isPro]
255
+ );
256
+
257
+ const remove = useCallback((id: string) => {
258
+ const current = readCustomPresetsFromStorage();
259
+ const next = current.filter((p) => p.id !== id);
260
+ const ok = writeCustomPresetsToStorage(next);
261
+ if (!ok) {
262
+ console.warn('[lokvis] Failed to persist custom presets removal');
263
+ }
264
+ setPresets(next);
265
+ }, []);
266
+
267
+ const get = useCallback((id: string): CustomSizePreset | null => {
268
+ const current = readCustomPresetsFromStorage();
269
+ return current.find((p) => p.id === id) ?? null;
270
+ }, []);
271
+
272
+ const canSaveMore = presets.length < limit;
273
+ const remaining = Math.max(0, limit - presets.length);
274
+
275
+ return {
276
+ presets,
277
+ save,
278
+ remove,
279
+ get,
280
+ limit,
281
+ canSaveMore,
282
+ remaining,
283
+ };
284
+ }
@@ -14,12 +14,20 @@
14
14
  * return <Workspace />;
15
15
  * }
16
16
  * ```
17
+ *
18
+ * @example 接 cloud session(W17.6)
19
+ * ```tsx
20
+ * const { runtime } = useLokvis({
21
+ * plugins: [imageToolsPlugin()],
22
+ * auth: { session: cloudJwt }, // → isPro: true,批量/槽位无上限
23
+ * });
24
+ * ```
17
25
  */
18
26
 
19
27
  import { useEffect, useRef, useState } from 'react';
20
28
  import { createLokvis } from '@lokvis/sdk';
21
29
  import type { LokvisRuntime, RuntimeConfig } from '@lokvis/runtime';
22
- import type { PluginLoadEntry } from '@lokvis/sdk';
30
+ import type { LokvisAuthSession, PluginLoadEntry } from '@lokvis/sdk';
23
31
  import { useWorkspaceStore } from '../store/index.js';
24
32
 
25
33
  export interface UseLokvisOptions extends RuntimeConfig {
@@ -27,6 +35,16 @@ export interface UseLokvisOptions extends RuntimeConfig {
27
35
  plugins?: PluginLoadEntry[];
28
36
  /** 是否在挂载时自动初始化(默认 true) */
29
37
  autoInit?: boolean;
38
+ /**
39
+ * Cloud 注入的认证信息(W17.3 / W17.6)。
40
+ *
41
+ * 透传到 `createLokvis({ auth })`,SDK 据 presence 推导 `isPro`:
42
+ * - 传入非空 `session` 或 `token` 且未显式 `isPro: false` → Pro 模式
43
+ * - 不传 `auth`:保持 free 模式
44
+ *
45
+ * 详见 `@lokvis/sdk` 的 `LokvisAuthSession` 与 `resolveIsPro()` 文档。
46
+ */
47
+ auth?: LokvisAuthSession;
30
48
  }
31
49
 
32
50
  export interface UseLokvisResult {
@@ -36,29 +54,45 @@ export interface UseLokvisResult {
36
54
  }
37
55
 
38
56
  export function useLokvis(options: UseLokvisOptions = {}): UseLokvisResult {
39
- const { plugins = [], autoInit = true, ...runtimeConfig } = options;
57
+ const { plugins = [], autoInit = true, auth, ...runtimeConfig } = options;
40
58
  const [runtime, setRuntime] = useState<LokvisRuntime | null>(null);
41
59
  const [status, setStatus] = useState<UseLokvisResult['status']>('idle');
42
60
  const [error, setError] = useState<string | null>(null);
43
- const initRef = useRef(false);
44
61
 
45
62
  const storeInit = useWorkspaceStore((s) => s.init);
46
63
 
64
+ // 用 useRef 持有 plugins / runtimeConfig / auth / storeInit 的最新值,
65
+ // 避免 effect 依赖这些对象引用导致每次 render 都重新初始化 runtime。
66
+ // effect 仅在 authKey / autoInit 变化时触发,内部从 ref 读取当前值。
67
+ const pluginsRef = useRef(plugins);
68
+ const runtimeConfigRef = useRef(runtimeConfig);
69
+ const authRef = useRef(auth);
70
+ const storeInitRef = useRef(storeInit);
71
+ pluginsRef.current = plugins;
72
+ runtimeConfigRef.current = runtimeConfig;
73
+ authRef.current = auth;
74
+ storeInitRef.current = storeInit;
75
+
76
+ // 用 JSON.stringify(auth) 作为 effect 依赖,使 auth 变化时重新初始化。
77
+ // (auth 是对象,直接放依赖数组会因引用变化每次 render 都触发;
78
+ // JSON.stringify 提供稳定的 primitive 依赖,只在 auth 内容变化时重 init)
79
+ const authKey = JSON.stringify(auth ?? null);
80
+
47
81
  useEffect(() => {
48
- if (!autoInit || initRef.current) return;
49
- initRef.current = true;
82
+ if (!autoInit) return;
50
83
 
51
84
  let cancelled = false;
52
85
  (async () => {
53
86
  try {
54
87
  setStatus('initializing');
55
88
  const rt = await createLokvis({
56
- ...runtimeConfig,
57
- plugins,
89
+ ...runtimeConfigRef.current,
90
+ plugins: pluginsRef.current,
91
+ auth: authRef.current,
58
92
  });
59
93
  if (cancelled) return;
60
94
  setRuntime(rt);
61
- await storeInit(rt);
95
+ await storeInitRef.current(rt);
62
96
  if (cancelled) return;
63
97
  setStatus('ready');
64
98
  } catch (err) {
@@ -71,8 +105,7 @@ export function useLokvis(options: UseLokvisOptions = {}): UseLokvisResult {
71
105
  return () => {
72
106
  cancelled = true;
73
107
  };
74
- // oxlint-disable-next-line react-hooks/exhaustive-deps
75
- }, []);
108
+ }, [authKey, autoInit]);
76
109
 
77
110
  return { runtime, status, error };
78
111
  }
package/src/index.ts CHANGED
@@ -31,11 +31,13 @@ export { useLokvis, type UseLokvisOptions, type UseLokvisResult } from './hooks/
31
31
  export { useTheme, type UseThemeResult, type ThemeMode } from './hooks/useTheme.js';
32
32
  export { useMediaQuery, useBreakpoints, type Breakpoints } from './hooks/useMediaQuery.js';
33
33
  export { useWorkflows, FREE_WORKFLOW_LIMIT, PRO_WORKFLOW_LIMIT, type UseWorkflowsResult, type WorkflowSlot, type SaveWorkflowInput } from './hooks/useWorkflows.js';
34
+ export { useCustomPresets, FREE_PRESET_LIMIT, PRO_PRESET_LIMIT, readCustomPresetsFromStorage, writeCustomPresetsToStorage, genCustomPresetId, type UseCustomPresetsResult, type CustomSizePreset, type SavePresetInput } from './hooks/useCustomPresets.js';
34
35
  export { useDebouncedRun, type UseDebouncedRunOptions, type UseDebouncedRunResult } from './hooks/useDebouncedRun.js';
35
36
  export { useShareLink, type UseShareLinkResult, encodeWorkflowForShare, decodeWorkflowFromShare } from './hooks/useShareLink.js';
36
37
  export { useWorkspaceStore, type WorkspaceState, type WorkspaceActions } from './store/index.js';
37
38
 
38
39
  export { Workspace, type WorkspaceProps } from './components/Workspace.js';
40
+ export { ErrorBoundary, type ErrorBoundaryProps } from './components/ErrorBoundary.js';
39
41
  export { AssetPanel } from './components/AssetPanel.js';
40
42
  export { Canvas } from './components/Canvas.js';
41
43
  export { Inspector } from './components/Inspector.js';
@@ -54,6 +56,7 @@ export { GlobalDropzone, type GlobalDropzoneProps } from './components/GlobalDro
54
56
  export { CompareSlider, type CompareSliderProps } from './components/CompareSlider.js';
55
57
  export { DownloadPanel, type DownloadPanelProps } from './components/DownloadPanel.js';
56
58
  export { ThemeToggle, type ThemeToggleProps } from './components/ThemeToggle.js';
59
+ export { ToolRunner, type ToolRunnerProps } from './components/ToolRunner.js';
57
60
 
58
61
  export {
59
62
  WORKFLOW_TEMPLATES,