@lokvis/ui-react 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +18 -62
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts +4 -4
  5. package/dist/components/Canvas.d.ts.map +1 -1
  6. package/dist/components/Canvas.js +9 -9
  7. package/dist/components/Canvas.js.map +1 -1
  8. package/dist/components/CommandPalette.d.ts +7 -7
  9. package/dist/components/CommandPalette.d.ts.map +1 -1
  10. package/dist/components/CommandPalette.js +10 -10
  11. package/dist/components/CommandPalette.js.map +1 -1
  12. package/dist/components/CompareSlider.d.ts +9 -8
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +10 -9
  15. package/dist/components/CompareSlider.js.map +1 -1
  16. package/dist/components/DownloadPanel.d.ts +3 -3
  17. package/dist/components/DownloadPanel.d.ts.map +1 -1
  18. package/dist/components/DownloadPanel.js +10 -17
  19. package/dist/components/DownloadPanel.js.map +1 -1
  20. package/dist/components/ErrorBanner.d.ts +2 -2
  21. package/dist/components/ErrorBanner.d.ts.map +1 -1
  22. package/dist/components/ErrorBanner.js +3 -3
  23. package/dist/components/ErrorBanner.js.map +1 -1
  24. package/dist/components/ErrorBoundary.d.ts +40 -0
  25. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  26. package/dist/components/ErrorBoundary.js +52 -0
  27. package/dist/components/ErrorBoundary.js.map +1 -0
  28. package/dist/components/ExifPanel.d.ts +1 -1
  29. package/dist/components/ExifPanel.d.ts.map +1 -1
  30. package/dist/components/ExifPanel.js +2 -2
  31. package/dist/components/ExifPanel.js.map +1 -1
  32. package/dist/components/GlobalDropzone.d.ts +5 -5
  33. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  34. package/dist/components/GlobalDropzone.js +15 -8
  35. package/dist/components/GlobalDropzone.js.map +1 -1
  36. package/dist/components/HistoryPanel.d.ts +2 -2
  37. package/dist/components/HistoryPanel.d.ts.map +1 -1
  38. package/dist/components/HistoryPanel.js +23 -23
  39. package/dist/components/HistoryPanel.js.map +1 -1
  40. package/dist/components/Inspector.d.ts.map +1 -1
  41. package/dist/components/Inspector.js +13 -10
  42. package/dist/components/Inspector.js.map +1 -1
  43. package/dist/components/ParamForm.d.ts.map +1 -1
  44. package/dist/components/ParamForm.js +10 -7
  45. package/dist/components/ParamForm.js.map +1 -1
  46. package/dist/components/PipelineBar.d.ts.map +1 -1
  47. package/dist/components/PipelineBar.js +19 -28
  48. package/dist/components/PipelineBar.js.map +1 -1
  49. package/dist/components/ProgressBar.d.ts +4 -4
  50. package/dist/components/ProgressBar.d.ts.map +1 -1
  51. package/dist/components/ProgressBar.js +8 -8
  52. package/dist/components/ProgressBar.js.map +1 -1
  53. package/dist/components/StatusBar.d.ts +3 -3
  54. package/dist/components/StatusBar.d.ts.map +1 -1
  55. package/dist/components/StatusBar.js +12 -21
  56. package/dist/components/StatusBar.js.map +1 -1
  57. package/dist/components/StatusDot.d.ts +12 -0
  58. package/dist/components/StatusDot.d.ts.map +1 -0
  59. package/dist/components/StatusDot.js +15 -0
  60. package/dist/components/StatusDot.js.map +1 -0
  61. package/dist/components/ThemeToggle.d.ts +4 -4
  62. package/dist/components/ThemeToggle.d.ts.map +1 -1
  63. package/dist/components/ThemeToggle.js +7 -7
  64. package/dist/components/ThemeToggle.js.map +1 -1
  65. package/dist/components/ToolRunner.d.ts +53 -0
  66. package/dist/components/ToolRunner.d.ts.map +1 -0
  67. package/dist/components/ToolRunner.js +142 -0
  68. package/dist/components/ToolRunner.js.map +1 -0
  69. package/dist/components/Toolbar.d.ts.map +1 -1
  70. package/dist/components/Toolbar.js +3 -3
  71. package/dist/components/Toolbar.js.map +1 -1
  72. package/dist/components/WorkflowEditor.d.ts +6 -6
  73. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  74. package/dist/components/WorkflowEditor.js +40 -49
  75. package/dist/components/WorkflowEditor.js.map +1 -1
  76. package/dist/components/WorkflowTemplates.d.ts +2 -2
  77. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  78. package/dist/components/WorkflowTemplates.js +17 -7
  79. package/dist/components/WorkflowTemplates.js.map +1 -1
  80. package/dist/components/Workspace.d.ts +45 -23
  81. package/dist/components/Workspace.d.ts.map +1 -1
  82. package/dist/components/Workspace.js +87 -40
  83. package/dist/components/Workspace.js.map +1 -1
  84. package/dist/hooks/useCustomPresets.d.ts +98 -0
  85. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  86. package/dist/hooks/useCustomPresets.js +210 -0
  87. package/dist/hooks/useCustomPresets.js.map +1 -0
  88. package/dist/hooks/useLokvis.d.ts +19 -1
  89. package/dist/hooks/useLokvis.d.ts.map +1 -1
  90. package/dist/hooks/useLokvis.js +30 -9
  91. package/dist/hooks/useLokvis.js.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +6 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/store/assets-slice.d.ts +8 -1
  97. package/dist/store/assets-slice.d.ts.map +1 -1
  98. package/dist/store/assets-slice.js +101 -54
  99. package/dist/store/assets-slice.js.map +1 -1
  100. package/dist/store/capabilities-slice.d.ts +1 -1
  101. package/dist/store/capabilities-slice.d.ts.map +1 -1
  102. package/dist/store/capabilities-slice.js +8 -1
  103. package/dist/store/capabilities-slice.js.map +1 -1
  104. package/dist/store/index.d.ts +1 -1
  105. package/dist/store/index.d.ts.map +1 -1
  106. package/dist/store/index.js +1 -1
  107. package/dist/store/index.js.map +1 -1
  108. package/dist/store/runtime-slice.d.ts.map +1 -1
  109. package/dist/store/runtime-slice.js +22 -16
  110. package/dist/store/runtime-slice.js.map +1 -1
  111. package/dist/store/subscribe-utils.d.ts +45 -0
  112. package/dist/store/subscribe-utils.d.ts.map +1 -0
  113. package/dist/store/subscribe-utils.js +69 -0
  114. package/dist/store/subscribe-utils.js.map +1 -0
  115. package/dist/store/types.d.ts +19 -11
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/types.js +0 -10
  118. package/dist/store/types.js.map +1 -1
  119. package/dist/store/workflow-slice.d.ts +1 -1
  120. package/dist/store/workflow-slice.d.ts.map +1 -1
  121. package/dist/store/workflow-slice.js +19 -51
  122. package/dist/store/workflow-slice.js.map +1 -1
  123. package/dist/utils.d.ts +24 -0
  124. package/dist/utils.d.ts.map +1 -0
  125. package/dist/utils.js +31 -0
  126. package/dist/utils.js.map +1 -0
  127. package/package.json +7 -6
  128. package/src/components/AssetPanel.tsx +224 -264
  129. package/src/components/Canvas.tsx +178 -174
  130. package/src/components/CommandPalette.tsx +242 -242
  131. package/src/components/CompareSlider.tsx +168 -165
  132. package/src/components/DownloadPanel.tsx +201 -207
  133. package/src/components/ErrorBanner.tsx +64 -64
  134. package/src/components/ErrorBoundary.tsx +101 -0
  135. package/src/components/ExifPanel.tsx +154 -154
  136. package/src/components/GlobalDropzone.tsx +175 -170
  137. package/src/components/HistoryPanel.tsx +219 -219
  138. package/src/components/Inspector.tsx +145 -137
  139. package/src/components/ParamForm.tsx +140 -137
  140. package/src/components/PipelineBar.tsx +97 -104
  141. package/src/components/ProgressBar.tsx +78 -78
  142. package/src/components/StatusBar.tsx +145 -152
  143. package/src/components/StatusDot.tsx +27 -0
  144. package/src/components/ThemeToggle.tsx +98 -98
  145. package/src/components/ToolRunner.tsx +352 -0
  146. package/src/components/Toolbar.tsx +65 -65
  147. package/src/components/WorkflowEditor.tsx +333 -326
  148. package/src/components/WorkflowTemplates.tsx +101 -78
  149. package/src/components/Workspace.tsx +310 -223
  150. package/src/css-modules.d.ts +3 -0
  151. package/src/hooks/useCustomPresets.ts +284 -0
  152. package/src/hooks/useLokvis.ts +43 -10
  153. package/src/index.ts +7 -0
  154. package/src/store/assets-slice.ts +106 -52
  155. package/src/store/capabilities-slice.ts +10 -2
  156. package/src/store/index.ts +1 -1
  157. package/src/store/runtime-slice.ts +20 -15
  158. package/src/store/subscribe-utils.ts +82 -0
  159. package/src/store/types.ts +19 -12
  160. package/src/store/workflow-slice.ts +19 -53
  161. package/src/utils.ts +41 -0
  162. package/dist/store.d.ts +0 -76
  163. package/dist/store.d.ts.map +0 -1
  164. package/dist/store.js +0 -217
  165. package/dist/store.js.map +0 -1
@@ -0,0 +1,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
@@ -23,15 +23,21 @@
23
23
  * - `useCommandPalette()` - ⌘K shortcut registration hook (W9.2)
24
24
  */
25
25
 
26
+ // 引入设计 token(--lokvis-* CSS 变量),消费方无需手动 import styles.css。
27
+ // sideEffects: ["**/*.css"] 保证 bundler 不 tree-shake 此 import。
28
+ import '@lokvis/ui-core/styles.css';
29
+
26
30
  export { useLokvis, type UseLokvisOptions, type UseLokvisResult } from './hooks/useLokvis.js';
27
31
  export { useTheme, type UseThemeResult, type ThemeMode } from './hooks/useTheme.js';
28
32
  export { useMediaQuery, useBreakpoints, type Breakpoints } from './hooks/useMediaQuery.js';
29
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';
30
35
  export { useDebouncedRun, type UseDebouncedRunOptions, type UseDebouncedRunResult } from './hooks/useDebouncedRun.js';
31
36
  export { useShareLink, type UseShareLinkResult, encodeWorkflowForShare, decodeWorkflowFromShare } from './hooks/useShareLink.js';
32
37
  export { useWorkspaceStore, type WorkspaceState, type WorkspaceActions } from './store/index.js';
33
38
 
34
39
  export { Workspace, type WorkspaceProps } from './components/Workspace.js';
40
+ export { ErrorBoundary, type ErrorBoundaryProps } from './components/ErrorBoundary.js';
35
41
  export { AssetPanel } from './components/AssetPanel.js';
36
42
  export { Canvas } from './components/Canvas.js';
37
43
  export { Inspector } from './components/Inspector.js';
@@ -50,6 +56,7 @@ export { GlobalDropzone, type GlobalDropzoneProps } from './components/GlobalDro
50
56
  export { CompareSlider, type CompareSliderProps } from './components/CompareSlider.js';
51
57
  export { DownloadPanel, type DownloadPanelProps } from './components/DownloadPanel.js';
52
58
  export { ThemeToggle, type ThemeToggleProps } from './components/ThemeToggle.js';
59
+ export { ToolRunner, type ToolRunnerProps } from './components/ToolRunner.js';
53
60
 
54
61
  export {
55
62
  WORKFLOW_TEMPLATES,
@@ -1,5 +1,12 @@
1
1
  /**
2
2
  * Assets slice —— 资产导入 / 选择 / 删除 / 缩略图
3
+ *
4
+ * TD-5.1 长期方案:缩略图 ObjectURL 的创建 / 替换 / 释放统一在 store 内管理,
5
+ * 消除原先"创建在 AssetPanel effect、释放在 removeAsset action"的跨边界脆弱性。
6
+ * - ensureThumbnails:iterate assets → exportAsset → createObjectURL → setThumbnail
7
+ * - setThumbnail:替换时自动 revoke 旧 ObjectURL
8
+ * - removeAsset:删除时 revoke ObjectURL + 清理 entry
9
+ * inflight 去重通过闭包捕获的 Set(非响应式,不触发额外 re-render)。
3
10
  */
4
11
  import type { StateCreator } from 'zustand';
5
12
  import type { WorkspaceStore, WorkspaceState, WorkspaceActions } from './types.js';
@@ -8,7 +15,12 @@ export interface AssetsSlice
8
15
  extends Pick<WorkspaceState, 'assets' | 'selectedAssetId' | 'thumbnails'>,
9
16
  Pick<
10
17
  WorkspaceActions,
11
- 'refreshAssets' | 'importFiles' | 'selectAsset' | 'setThumbnail' | 'removeAsset'
18
+ | 'refreshAssets'
19
+ | 'importFiles'
20
+ | 'selectAsset'
21
+ | 'setThumbnail'
22
+ | 'ensureThumbnails'
23
+ | 'removeAsset'
12
24
  > {}
13
25
 
14
26
  export const createAssetsSlice: StateCreator<
@@ -16,61 +28,103 @@ export const createAssetsSlice: StateCreator<
16
28
  [],
17
29
  [],
18
30
  AssetsSlice
19
- > = (set, get) => ({
20
- assets: [],
21
- selectedAssetId: null,
22
- thumbnails: {},
31
+ > = (set, get) => {
32
+ // TD-5.1:per-store inflight 缩略图去重(闭包捕获,非响应式,不触发 re-render)
33
+ const inflightThumbnails = new Set<string>();
23
34
 
24
- async refreshAssets() {
25
- const { runtime } = get();
26
- if (!runtime) return;
27
- const assets = await runtime.listAssets();
28
- set({ assets });
29
- },
35
+ return {
36
+ assets: [],
37
+ selectedAssetId: null,
38
+ thumbnails: {},
30
39
 
31
- async importFiles(files) {
32
- const { runtime } = get();
33
- if (!runtime) return;
34
- set({ statusMessage: `Importing ${files.length} file(s)...` });
35
- for (const file of files) {
36
- await runtime.importAsset({ kind: 'file', file });
37
- }
38
- await get().refreshAssets();
39
- await get().refreshStorageUsage();
40
- set({ statusMessage: `Imported ${files.length} file(s)` });
41
- },
40
+ async refreshAssets() {
41
+ const { runtime } = get();
42
+ if (!runtime) return;
43
+ const assets = await runtime.listAssets();
44
+ set({ assets });
45
+ },
42
46
 
43
- selectAsset(id) {
44
- set({ selectedAssetId: id });
45
- },
46
-
47
- setThumbnail(id, url) {
48
- set((state) => ({ thumbnails: { ...state.thumbnails, [id]: url } }));
49
- },
47
+ async importFiles(files) {
48
+ const { runtime } = get();
49
+ if (!runtime) return;
50
+ set({ statusMessage: `Importing ${files.length} file(s)...` });
51
+ for (const file of files) {
52
+ await runtime.importAsset({ kind: 'file', file });
53
+ }
54
+ await get().refreshAssets();
55
+ await get().refreshStorageUsage();
56
+ set({ statusMessage: `Imported ${files.length} file(s)` });
57
+ },
50
58
 
51
- async removeAsset(id) {
52
- const { runtime } = get();
53
- if (!runtime) return;
54
- await runtime.removeAsset(id);
55
- await get().refreshAssets();
56
- await get().refreshStorageUsage();
57
- if (get().selectedAssetId === id) set({ selectedAssetId: null });
59
+ selectAsset(id) {
60
+ set({ selectedAssetId: id });
61
+ },
58
62
 
59
- // 修复 review 报告:原实现删除 asset 后未 revoke 缩略图 ObjectURL,导致
60
- // 浏览器 Blob 引用泄漏(每删一张图就漏一个 blob 内存)。
61
- // 同时从 thumbnails map 中删除该 entry,避免 stale ref
62
- const oldUrl = get().thumbnails[id];
63
- if (oldUrl) {
64
- try {
65
- URL.revokeObjectURL(oldUrl);
66
- } catch {
67
- /* URL 已失效或非 ObjectURL,忽略 */
68
- }
63
+ setThumbnail(id, url) {
64
+ // TD-5.1:替换时 revoke ObjectURL,集中管理释放,避免孤儿 URL
69
65
  set((state) => {
70
- const next = { ...state.thumbnails };
71
- delete next[id];
72
- return { thumbnails: next };
66
+ const old = state.thumbnails[id];
67
+ if (old) {
68
+ try {
69
+ URL.revokeObjectURL(old);
70
+ } catch {
71
+ /* URL 已失效或非 ObjectURL,忽略 */
72
+ }
73
+ }
74
+ return { thumbnails: { ...state.thumbnails, [id]: url } };
73
75
  });
74
- }
75
- },
76
- });
76
+ },
77
+
78
+ ensureThumbnails() {
79
+ const { runtime, assets, thumbnails } = get();
80
+ if (!runtime) return;
81
+ for (const asset of assets) {
82
+ if (thumbnails[asset.id]) continue;
83
+ if (asset.type !== 'image') continue;
84
+ if (inflightThumbnails.has(asset.id)) continue;
85
+ inflightThumbnails.add(asset.id);
86
+ void (async () => {
87
+ try {
88
+ const blob = await runtime.exportAsset(asset.id);
89
+ const url = URL.createObjectURL(blob);
90
+ // 资产可能在 await 期间被删除:若已不在 assets 中,revoke URL 不入 store
91
+ if (!get().assets.some((a) => a.id === asset.id)) {
92
+ URL.revokeObjectURL(url);
93
+ return;
94
+ }
95
+ get().setThumbnail(asset.id, url);
96
+ } catch {
97
+ // export 失败,不留缩略图(下次 ensureThumbnails 会重试)
98
+ } finally {
99
+ inflightThumbnails.delete(asset.id);
100
+ }
101
+ })();
102
+ }
103
+ },
104
+
105
+ async removeAsset(id) {
106
+ const { runtime } = get();
107
+ if (!runtime) return;
108
+ await runtime.removeAsset(id);
109
+ await get().refreshAssets();
110
+ await get().refreshStorageUsage();
111
+ if (get().selectedAssetId === id) set({ selectedAssetId: null });
112
+
113
+ // TD-5.1:删除资产时 revoke 缩略图 ObjectURL + 清理 entry(与 setThumbnail
114
+ // 的 replace-revoke 一起,构成"创建/替换/释放"全在 store 内的闭环)
115
+ const oldUrl = get().thumbnails[id];
116
+ if (oldUrl) {
117
+ try {
118
+ URL.revokeObjectURL(oldUrl);
119
+ } catch {
120
+ /* URL 已失效或非 ObjectURL,忽略 */
121
+ }
122
+ set((state) => {
123
+ const next = { ...state.thumbnails };
124
+ delete next[id];
125
+ return { thumbnails: next };
126
+ });
127
+ }
128
+ },
129
+ };
130
+ };
@@ -6,7 +6,7 @@ import type { CapabilityMap } from '../types.js';
6
6
  import type { WorkspaceStore, WorkspaceState, WorkspaceActions } from './types.js';
7
7
 
8
8
  export interface CapabilitiesSlice
9
- extends Pick<WorkspaceState, 'capabilities' | 'capabilityMap'>,
9
+ extends Pick<WorkspaceState, 'capabilities' | 'capabilityMap' | 'stubCapabilities'>,
10
10
  Pick<WorkspaceActions, 'refreshCapabilities'> {}
11
11
 
12
12
  export const createCapabilitiesSlice: StateCreator<
@@ -17,6 +17,7 @@ export const createCapabilitiesSlice: StateCreator<
17
17
  > = (set, get) => ({
18
18
  capabilities: [],
19
19
  capabilityMap: {},
20
+ stubCapabilities: new Set<string>(),
20
21
 
21
22
  async refreshCapabilities() {
22
23
  const { runtime } = get();
@@ -24,6 +25,13 @@ export const createCapabilitiesSlice: StateCreator<
24
25
  const capabilities = await runtime.capabilities();
25
26
  const capabilityMap: CapabilityMap = {};
26
27
  for (const cap of capabilities) capabilityMap[cap.name] = cap;
27
- set({ capabilities, capabilityMap });
28
+ // A7: 探测 stub-only 能力,供 UI 显示 "Coming Soon" 标记
29
+ const stubCapabilities = new Set<string>();
30
+ await Promise.all(
31
+ capabilities.map(async (cap) => {
32
+ if (await runtime.isStubOnly(cap.name)) stubCapabilities.add(cap.name);
33
+ })
34
+ );
35
+ set({ capabilities, capabilityMap, stubCapabilities });
28
36
  },
29
37
  });
@@ -27,4 +27,4 @@ export const useWorkspaceStore = create<WorkspaceStore>()((...a) => ({
27
27
  }));
28
28
 
29
29
  export type { WorkspaceState, WorkspaceActions, WorkspaceStore } from './types.js';
30
- export { MAX_WORKFLOW_STEPS } from './types.js';
30
+ export { MAX_WORKFLOW_STEPS } from '@lokvis/schema';