@pixel-point/toolcraft 0.0.9 → 0.0.11
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.
- package/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +28 -0
- package/templates/runtime/contracts/component-contracts.test.ts +148 -34
- package/templates/runtime/contracts/component-contracts.ts +79 -38
- package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
- package/templates/runtime/contracts/decision-contracts.ts +19 -6
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +474 -27
- package/templates/runtime/react/controls-panel.tsx +71 -26
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
- package/templates/runtime/schema/define-toolcraft.ts +117 -247
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +41 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +657 -2
- package/templates/runtime/testing/performance.ts +789 -49
- package/templates/starter/AGENTS.md +22 -16
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
- package/templates/starter/docs/toolcraft/component-rules.md +49 -37
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +30 -10
- package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +1 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
- package/templates/starter/scripts/toolcraft-port.mjs +178 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
- package/templates/starter/src/app/starter-acceptance.ts +978 -81
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +2 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +4 -26
- package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
|
@@ -65,6 +65,42 @@ describe("createToolcraftState", () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it("keeps runtime timeline presentation out of product values and defaults", () => {
|
|
69
|
+
const app = defineToolcraft({
|
|
70
|
+
canvas: { enabled: true, renderScale: true, sizing: { mode: "editable-output" } },
|
|
71
|
+
panels: {
|
|
72
|
+
controls: {
|
|
73
|
+
sections: [],
|
|
74
|
+
title: "Controls",
|
|
75
|
+
},
|
|
76
|
+
timeline: { mode: "playback" },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const state = createToolcraftState(app);
|
|
81
|
+
|
|
82
|
+
expect(state.values).not.toHaveProperty("panels.timeline.extended");
|
|
83
|
+
expect(state.values).not.toHaveProperty("panels.timeline.visible");
|
|
84
|
+
expect(state.defaults).not.toHaveProperty("panels.timeline.extended");
|
|
85
|
+
expect(state.defaults).not.toHaveProperty("panels.timeline.visible");
|
|
86
|
+
expect(state.panels.timeline.extended).toBeUndefined();
|
|
87
|
+
expect(state.panels.timeline.hidden).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("initializes timeline duration from the schema loop duration", () => {
|
|
91
|
+
const app = defineToolcraft({
|
|
92
|
+
canvas: { enabled: true },
|
|
93
|
+
panels: {
|
|
94
|
+
timeline: { defaultDurationSeconds: 14, mode: "playback" },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const state = createToolcraftState(app);
|
|
99
|
+
|
|
100
|
+
expect(state.timeline.durationSeconds).toBe(14);
|
|
101
|
+
expect(state.timeline.isLooping).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
68
104
|
it("initializes canvas state from resolved canvas size", () => {
|
|
69
105
|
const size = { width: 1200, height: 900, unit: "px" } as const;
|
|
70
106
|
const app = defineToolcraft({
|
|
@@ -122,6 +158,126 @@ describe("createToolcraftState", () => {
|
|
|
122
158
|
expect(state.selectedLayerId).toBeNull();
|
|
123
159
|
});
|
|
124
160
|
|
|
161
|
+
it("initializes predefined media files as runtime attachments", () => {
|
|
162
|
+
const app = defineToolcraft({
|
|
163
|
+
canvas: { enabled: true, upload: true },
|
|
164
|
+
media: {
|
|
165
|
+
defaultAssets: [
|
|
166
|
+
{
|
|
167
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
168
|
+
fileName: "background.png",
|
|
169
|
+
id: "default-background",
|
|
170
|
+
layerId: "default-background-layer",
|
|
171
|
+
mimeType: "image/png",
|
|
172
|
+
size: { height: 1080, unit: "px", width: 1920 },
|
|
173
|
+
sourceTarget: "media.background",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
panels: {
|
|
178
|
+
controls: {
|
|
179
|
+
sections: [
|
|
180
|
+
{
|
|
181
|
+
controls: {
|
|
182
|
+
background: {
|
|
183
|
+
defaultValue: null,
|
|
184
|
+
target: "media.background",
|
|
185
|
+
type: "fileDrop",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
title: "Controls",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const state = createToolcraftState(app);
|
|
196
|
+
|
|
197
|
+
expect(state.mediaAssets).toEqual([
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
200
|
+
fileName: "background.png",
|
|
201
|
+
id: "default-background",
|
|
202
|
+
layerId: "default-background-layer",
|
|
203
|
+
mimeType: "image/png",
|
|
204
|
+
sourceTarget: "media.background",
|
|
205
|
+
}),
|
|
206
|
+
]);
|
|
207
|
+
expect(state.layers).toEqual([
|
|
208
|
+
expect.objectContaining({
|
|
209
|
+
id: "default-background-layer",
|
|
210
|
+
name: "background",
|
|
211
|
+
visible: true,
|
|
212
|
+
}),
|
|
213
|
+
]);
|
|
214
|
+
expect(state.selectedLayerId).toBe("default-background-layer");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("lets persisted media state override schema default media", () => {
|
|
218
|
+
const app = defineToolcraft({
|
|
219
|
+
canvas: { enabled: true, upload: true },
|
|
220
|
+
media: {
|
|
221
|
+
defaultAssets: [
|
|
222
|
+
{
|
|
223
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
224
|
+
fileName: "background.png",
|
|
225
|
+
sourceTarget: "media.background",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
panels: {},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const state = createToolcraftState(app, { mediaAssets: [] });
|
|
233
|
+
|
|
234
|
+
expect(state.mediaAssets).toEqual([]);
|
|
235
|
+
expect(state.layers).toEqual([]);
|
|
236
|
+
expect(state.selectedLayerId).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("restores default media layer names when persisted media omits layer state", () => {
|
|
240
|
+
const app = defineToolcraft({
|
|
241
|
+
canvas: { enabled: true, upload: true },
|
|
242
|
+
media: {
|
|
243
|
+
defaultAssets: [
|
|
244
|
+
{
|
|
245
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
246
|
+
fileName: "background.png",
|
|
247
|
+
id: "default-background",
|
|
248
|
+
layerId: "default-background-layer",
|
|
249
|
+
layerName: "Hero source",
|
|
250
|
+
sourceTarget: "media.background",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
panels: {},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const state = createToolcraftState(app, {
|
|
258
|
+
mediaAssets: [
|
|
259
|
+
{
|
|
260
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
261
|
+
fileName: "background.png",
|
|
262
|
+
id: "default-background",
|
|
263
|
+
layerId: "default-background-layer",
|
|
264
|
+
mimeType: "image/png",
|
|
265
|
+
position: { x: 0, y: 0 },
|
|
266
|
+
sourceTarget: "media.background",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(state.layers).toEqual([
|
|
272
|
+
expect.objectContaining({
|
|
273
|
+
displayName: "Hero source",
|
|
274
|
+
id: "default-background-layer",
|
|
275
|
+
name: "Hero source",
|
|
276
|
+
}),
|
|
277
|
+
]);
|
|
278
|
+
expect(state.selectedLayerId).toBe("default-background-layer");
|
|
279
|
+
});
|
|
280
|
+
|
|
125
281
|
it("accepts seeded runtime layer state", () => {
|
|
126
282
|
const app = defineToolcraft({
|
|
127
283
|
canvas: { enabled: true },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedToolcraftAppSchema } from "../schema/types";
|
|
2
|
+
import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
|
|
2
3
|
import type {
|
|
3
4
|
ToolcraftInitialState,
|
|
4
5
|
ToolcraftState,
|
|
@@ -6,6 +7,12 @@ import type {
|
|
|
6
7
|
ToolcraftTimelineState,
|
|
7
8
|
} from "./types";
|
|
8
9
|
import { toolcraftCanvasZoomDefault } from "./canvas-zoom";
|
|
10
|
+
import {
|
|
11
|
+
cloneToolcraftLayers,
|
|
12
|
+
cloneToolcraftMediaAssets,
|
|
13
|
+
createToolcraftDefaultMediaState,
|
|
14
|
+
createToolcraftLayersFromMediaAssets,
|
|
15
|
+
} from "./media-defaults";
|
|
9
16
|
import { getMediaReadyTimelineState } from "./timeline-readiness";
|
|
10
17
|
|
|
11
18
|
function cloneTimelineKeyframeGroups(
|
|
@@ -26,12 +33,16 @@ function cloneTimelineKeyframeGroups(
|
|
|
26
33
|
}));
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
function createDefaultTimelineState(
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
function createDefaultTimelineState({
|
|
37
|
+
defaultDurationSeconds,
|
|
38
|
+
timeline,
|
|
39
|
+
}: {
|
|
40
|
+
defaultDurationSeconds: number;
|
|
41
|
+
timeline?: Partial<ToolcraftTimelineState>;
|
|
42
|
+
}): ToolcraftTimelineState {
|
|
32
43
|
return {
|
|
33
44
|
currentTimeSeconds: 0,
|
|
34
|
-
durationSeconds:
|
|
45
|
+
durationSeconds: defaultDurationSeconds,
|
|
35
46
|
expanded: false,
|
|
36
47
|
isLooping: true,
|
|
37
48
|
isPlaying: true,
|
|
@@ -46,15 +57,34 @@ export function createToolcraftState(
|
|
|
46
57
|
initialState: ToolcraftInitialState = {},
|
|
47
58
|
): ToolcraftState {
|
|
48
59
|
const defaults: Record<string, unknown> = {};
|
|
49
|
-
const
|
|
60
|
+
const defaultMediaState = createToolcraftDefaultMediaState(schema);
|
|
61
|
+
const hasInitialMediaAssets = Object.hasOwn(initialState, "mediaAssets");
|
|
62
|
+
const mediaAssets = hasInitialMediaAssets
|
|
63
|
+
? cloneToolcraftMediaAssets(initialState.mediaAssets ?? [])
|
|
64
|
+
: cloneToolcraftMediaAssets(defaultMediaState.mediaAssets);
|
|
65
|
+
const layers =
|
|
66
|
+
initialState.layers ??
|
|
67
|
+
(hasInitialMediaAssets
|
|
68
|
+
? createToolcraftLayersFromMediaAssets(mediaAssets, defaultMediaState.layers)
|
|
69
|
+
: cloneToolcraftLayers(defaultMediaState.layers));
|
|
70
|
+
const selectedLayerId =
|
|
71
|
+
initialState.selectedLayerId ??
|
|
72
|
+
(hasInitialMediaAssets ? (layers[0]?.id ?? null) : defaultMediaState.selectedLayerId);
|
|
50
73
|
const timeline = getMediaReadyTimelineState(
|
|
51
74
|
schema,
|
|
52
|
-
createDefaultTimelineState(
|
|
75
|
+
createDefaultTimelineState({
|
|
76
|
+
defaultDurationSeconds: schema.panels.timeline?.defaultDurationSeconds ?? 8,
|
|
77
|
+
timeline: initialState.timeline,
|
|
78
|
+
}),
|
|
53
79
|
mediaAssets,
|
|
54
80
|
);
|
|
55
81
|
|
|
56
82
|
for (const section of schema.panels.controls?.sections ?? []) {
|
|
57
83
|
for (const control of Object.values(section.controls)) {
|
|
84
|
+
if (isToolcraftTimelinePanelRuntimeTarget(control.target)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
defaults[control.target] = control.defaultValue;
|
|
59
89
|
}
|
|
60
90
|
}
|
|
@@ -79,7 +109,7 @@ export function createToolcraftState(
|
|
|
79
109
|
redo: [],
|
|
80
110
|
undo: [],
|
|
81
111
|
},
|
|
82
|
-
layers
|
|
112
|
+
layers,
|
|
83
113
|
mediaAssets,
|
|
84
114
|
panels: {
|
|
85
115
|
controls: { ...panels.controls, ...initialState.panels?.controls },
|
|
@@ -88,7 +118,7 @@ export function createToolcraftState(
|
|
|
88
118
|
toolbar: { ...panels.toolbar, ...initialState.panels?.toolbar },
|
|
89
119
|
},
|
|
90
120
|
schema,
|
|
91
|
-
selectedLayerId
|
|
121
|
+
selectedLayerId,
|
|
92
122
|
timeline,
|
|
93
123
|
values: { ...defaults, ...initialState.values },
|
|
94
124
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ResolvedToolcraftAppSchema } from "../schema/types";
|
|
2
|
+
import type { ToolcraftLayer, ToolcraftMediaAsset } from "./types";
|
|
3
|
+
|
|
4
|
+
export type ToolcraftDefaultMediaState = {
|
|
5
|
+
layers: ToolcraftLayer[];
|
|
6
|
+
mediaAssets: ToolcraftMediaAsset[];
|
|
7
|
+
selectedLayerId: string | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function getDefaultLayerName(fileName: string): string {
|
|
11
|
+
const baseName = fileName.replace(/\.[^.]+$/u, "").trim();
|
|
12
|
+
|
|
13
|
+
return baseName || "Media";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cloneMediaAsset(asset: ToolcraftMediaAsset): ToolcraftMediaAsset {
|
|
17
|
+
return {
|
|
18
|
+
...asset,
|
|
19
|
+
position: { ...asset.position },
|
|
20
|
+
...(asset.size ? { size: { ...asset.size } } : {}),
|
|
21
|
+
...(asset.transform ? { transform: { ...asset.transform } } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cloneLayer(layer: ToolcraftLayer): ToolcraftLayer {
|
|
26
|
+
return { ...layer };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cloneToolcraftMediaAssets(
|
|
30
|
+
mediaAssets: readonly ToolcraftMediaAsset[],
|
|
31
|
+
): ToolcraftMediaAsset[] {
|
|
32
|
+
return mediaAssets.map(cloneMediaAsset);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function cloneToolcraftLayers(layers: readonly ToolcraftLayer[]): ToolcraftLayer[] {
|
|
36
|
+
return layers.map(cloneLayer);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createToolcraftLayersFromMediaAssets(
|
|
40
|
+
mediaAssets: readonly ToolcraftMediaAsset[],
|
|
41
|
+
defaultLayers: readonly ToolcraftLayer[] = [],
|
|
42
|
+
): ToolcraftLayer[] {
|
|
43
|
+
const layers: ToolcraftLayer[] = [];
|
|
44
|
+
const defaultLayerById = new Map(defaultLayers.map((layer) => [layer.id, layer]));
|
|
45
|
+
const seenLayerIds = new Set<string>();
|
|
46
|
+
|
|
47
|
+
for (const asset of mediaAssets) {
|
|
48
|
+
if (seenLayerIds.has(asset.layerId)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
seenLayerIds.add(asset.layerId);
|
|
53
|
+
layers.push(
|
|
54
|
+
cloneLayer(
|
|
55
|
+
defaultLayerById.get(asset.layerId) ?? {
|
|
56
|
+
displayName: getDefaultLayerName(asset.fileName),
|
|
57
|
+
id: asset.layerId,
|
|
58
|
+
kind: "layer",
|
|
59
|
+
name: getDefaultLayerName(asset.fileName),
|
|
60
|
+
visible: true,
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return layers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createToolcraftDefaultMediaState(
|
|
70
|
+
schema: ResolvedToolcraftAppSchema,
|
|
71
|
+
): ToolcraftDefaultMediaState {
|
|
72
|
+
const layers: ToolcraftLayer[] = [];
|
|
73
|
+
const mediaAssets: ToolcraftMediaAsset[] = [];
|
|
74
|
+
|
|
75
|
+
schema.media.defaultAssets.forEach((asset, index) => {
|
|
76
|
+
const layerId = asset.layerId ?? `default-media-layer-${index + 1}`;
|
|
77
|
+
const layerName = asset.layerName ?? getDefaultLayerName(asset.fileName);
|
|
78
|
+
|
|
79
|
+
layers.push({
|
|
80
|
+
displayName: layerName,
|
|
81
|
+
id: layerId,
|
|
82
|
+
kind: "layer",
|
|
83
|
+
name: layerName,
|
|
84
|
+
visible: true,
|
|
85
|
+
});
|
|
86
|
+
mediaAssets.push({
|
|
87
|
+
...(asset.assetKind ? { assetKind: asset.assetKind } : {}),
|
|
88
|
+
dataUrl: asset.dataUrl,
|
|
89
|
+
fileName: asset.fileName,
|
|
90
|
+
id: asset.id ?? `default-media-${index + 1}`,
|
|
91
|
+
layerId,
|
|
92
|
+
mimeType: asset.mimeType ?? (asset.assetKind === "file" ? "application/octet-stream" : "image/*"),
|
|
93
|
+
position: asset.position ?? { x: 0, y: 0 },
|
|
94
|
+
...(asset.size ? { size: asset.size } : {}),
|
|
95
|
+
...(asset.sourceTarget ? { sourceTarget: asset.sourceTarget } : {}),
|
|
96
|
+
...(asset.transform ? { transform: asset.transform } : {}),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
layers,
|
|
102
|
+
mediaAssets,
|
|
103
|
+
selectedLayerId: layers[0]?.id ?? null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -77,6 +77,7 @@ describe("Toolcraft template state persistence", () => {
|
|
|
77
77
|
],
|
|
78
78
|
panels: {
|
|
79
79
|
controls: { offset: { x: 20, y: 10 } },
|
|
80
|
+
timeline: { hidden: true },
|
|
80
81
|
},
|
|
81
82
|
selectedLayerId: "layer-1",
|
|
82
83
|
timeline: {
|
|
@@ -125,6 +126,7 @@ describe("Toolcraft template state persistence", () => {
|
|
|
125
126
|
],
|
|
126
127
|
panels: {
|
|
127
128
|
controls: { offset: { x: 20, y: 10 } },
|
|
129
|
+
timeline: { hidden: true },
|
|
128
130
|
},
|
|
129
131
|
selectedLayerId: "layer-1",
|
|
130
132
|
timeline: {
|
|
@@ -146,10 +148,63 @@ describe("Toolcraft template state persistence", () => {
|
|
|
146
148
|
expect(snapshot?.state.values).not.toHaveProperty("legacy.target");
|
|
147
149
|
});
|
|
148
150
|
|
|
151
|
+
it("persists media state when the app opts into media persistence", () => {
|
|
152
|
+
const schema = defineToolcraft({
|
|
153
|
+
canvas: { enabled: true, upload: true },
|
|
154
|
+
media: {
|
|
155
|
+
defaultAssets: [
|
|
156
|
+
{
|
|
157
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
158
|
+
fileName: "background.png",
|
|
159
|
+
id: "default-background",
|
|
160
|
+
layerId: "default-background-layer",
|
|
161
|
+
mimeType: "image/png",
|
|
162
|
+
sourceTarget: "media.background",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
panels: {
|
|
167
|
+
controls: {
|
|
168
|
+
sections: [
|
|
169
|
+
{
|
|
170
|
+
controls: {
|
|
171
|
+
background: {
|
|
172
|
+
defaultValue: null,
|
|
173
|
+
target: "media.background",
|
|
174
|
+
type: "fileDrop",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
title: "Controls",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
persistence: {
|
|
183
|
+
include: ["values", "panels", "media"],
|
|
184
|
+
key: "toolcraft:media-persistence-test:state:v1",
|
|
185
|
+
storage: "localStorage",
|
|
186
|
+
version: 1,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const state = createToolcraftState(schema, { mediaAssets: [] });
|
|
190
|
+
const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence);
|
|
191
|
+
const parsed = parseToolcraftPersistenceSnapshot(
|
|
192
|
+
schema,
|
|
193
|
+
JSON.stringify(snapshot),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(snapshot?.state.mediaAssets).toEqual([]);
|
|
197
|
+
expect(parsed?.mediaAssets).toEqual([]);
|
|
198
|
+
expect(createToolcraftState(schema, parsed).mediaAssets).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
149
201
|
it("parses matching versions and ignores stale targets or invalid versions", () => {
|
|
150
202
|
const schema = createPersistentSchema();
|
|
151
203
|
const rawValue = JSON.stringify({
|
|
152
204
|
state: {
|
|
205
|
+
panels: {
|
|
206
|
+
timeline: { hidden: true },
|
|
207
|
+
},
|
|
153
208
|
values: {
|
|
154
209
|
"generation.prompt": "Restored prompt",
|
|
155
210
|
"legacy.target": "remove me",
|
|
@@ -160,6 +215,9 @@ describe("Toolcraft template state persistence", () => {
|
|
|
160
215
|
});
|
|
161
216
|
|
|
162
217
|
expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({
|
|
218
|
+
panels: {
|
|
219
|
+
timeline: { hidden: true },
|
|
220
|
+
},
|
|
163
221
|
values: {
|
|
164
222
|
"generation.prompt": "Restored prompt",
|
|
165
223
|
"selectedLayer.opacity": 55,
|
|
@@ -2,10 +2,12 @@ import type {
|
|
|
2
2
|
ToolcraftLocalStoragePersistenceSchema,
|
|
3
3
|
ResolvedToolcraftAppSchema,
|
|
4
4
|
} from "../schema/types";
|
|
5
|
+
import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
|
|
5
6
|
import type {
|
|
6
7
|
ToolcraftCanvasState,
|
|
7
8
|
ToolcraftInitialState,
|
|
8
9
|
ToolcraftLayer,
|
|
10
|
+
ToolcraftMediaAsset,
|
|
9
11
|
ToolcraftPanelId,
|
|
10
12
|
ToolcraftPanelState,
|
|
11
13
|
ToolcraftState,
|
|
@@ -92,6 +94,14 @@ function readPanel(value: unknown): Partial<ToolcraftPanelState> | undefined {
|
|
|
92
94
|
panel.collapsed = value.collapsed;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
if (typeof value.extended === "boolean") {
|
|
98
|
+
panel.extended = value.extended;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof value.hidden === "boolean") {
|
|
102
|
+
panel.hidden = value.hidden;
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
return Object.keys(panel).length > 0 ? panel : undefined;
|
|
96
106
|
}
|
|
97
107
|
|
|
@@ -161,6 +171,85 @@ function readLayers(value: unknown): ToolcraftLayer[] | undefined {
|
|
|
161
171
|
});
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
function readMediaAsset(value: unknown): ToolcraftMediaAsset | undefined {
|
|
175
|
+
if (
|
|
176
|
+
!isRecord(value) ||
|
|
177
|
+
typeof value.id !== "string" ||
|
|
178
|
+
typeof value.layerId !== "string" ||
|
|
179
|
+
typeof value.dataUrl !== "string" ||
|
|
180
|
+
typeof value.fileName !== "string" ||
|
|
181
|
+
typeof value.mimeType !== "string"
|
|
182
|
+
) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const position = readPoint(value.position);
|
|
187
|
+
|
|
188
|
+
if (!position) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const mediaAsset: ToolcraftMediaAsset = {
|
|
193
|
+
dataUrl: value.dataUrl,
|
|
194
|
+
fileName: value.fileName,
|
|
195
|
+
id: value.id,
|
|
196
|
+
layerId: value.layerId,
|
|
197
|
+
mimeType: value.mimeType,
|
|
198
|
+
position,
|
|
199
|
+
};
|
|
200
|
+
const size = readCanvasSize(value.size);
|
|
201
|
+
|
|
202
|
+
if (value.assetKind === "file" || value.assetKind === "image") {
|
|
203
|
+
mediaAsset.assetKind = value.assetKind;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (size) {
|
|
207
|
+
mediaAsset.size = size;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof value.sourceTarget === "string") {
|
|
211
|
+
mediaAsset.sourceTarget = value.sourceTarget;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isRecord(value.transform)) {
|
|
215
|
+
mediaAsset.transform = {};
|
|
216
|
+
|
|
217
|
+
if (typeof value.transform.flipHorizontal === "boolean") {
|
|
218
|
+
mediaAsset.transform.flipHorizontal = value.transform.flipHorizontal;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (typeof value.transform.flipVertical === "boolean") {
|
|
222
|
+
mediaAsset.transform.flipVertical = value.transform.flipVertical;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
value.transform.rotationDeg === 0 ||
|
|
227
|
+
value.transform.rotationDeg === 90 ||
|
|
228
|
+
value.transform.rotationDeg === 180 ||
|
|
229
|
+
value.transform.rotationDeg === 270
|
|
230
|
+
) {
|
|
231
|
+
mediaAsset.transform.rotationDeg = value.transform.rotationDeg;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (Object.keys(mediaAsset.transform).length === 0) {
|
|
235
|
+
delete mediaAsset.transform;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return mediaAsset;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readMediaAssets(value: unknown): ToolcraftMediaAsset[] | undefined {
|
|
243
|
+
if (!Array.isArray(value)) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return value.flatMap((item) => {
|
|
248
|
+
const mediaAsset = readMediaAsset(item);
|
|
249
|
+
return mediaAsset ? [mediaAsset] : [];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
164
253
|
function readBezierControlPoints(
|
|
165
254
|
value: unknown,
|
|
166
255
|
): ToolcraftTimelineBezierControlPoints | undefined {
|
|
@@ -296,7 +385,10 @@ function getKnownValueTargets(schema: ResolvedToolcraftAppSchema): Set<string> {
|
|
|
296
385
|
|
|
297
386
|
for (const section of schema.panels.controls?.sections ?? []) {
|
|
298
387
|
for (const control of Object.values(section.controls)) {
|
|
299
|
-
if (
|
|
388
|
+
if (
|
|
389
|
+
control.type !== "panelActions" &&
|
|
390
|
+
!isToolcraftTimelinePanelRuntimeTarget(control.target)
|
|
391
|
+
) {
|
|
300
392
|
targets.add(control.target);
|
|
301
393
|
}
|
|
302
394
|
}
|
|
@@ -369,6 +461,10 @@ export function createToolcraftPersistenceSnapshot(
|
|
|
369
461
|
initialState.selectedLayerId = state.selectedLayerId;
|
|
370
462
|
}
|
|
371
463
|
|
|
464
|
+
if (included.has("media")) {
|
|
465
|
+
initialState.mediaAssets = state.mediaAssets;
|
|
466
|
+
}
|
|
467
|
+
|
|
372
468
|
return {
|
|
373
469
|
state: initialState,
|
|
374
470
|
version: persistence.version,
|
|
@@ -450,6 +546,14 @@ export function parseToolcraftPersistenceSnapshot(
|
|
|
450
546
|
}
|
|
451
547
|
}
|
|
452
548
|
|
|
549
|
+
if (included.has("media")) {
|
|
550
|
+
const mediaAssets = readMediaAssets(persistedState.mediaAssets);
|
|
551
|
+
|
|
552
|
+
if (mediaAssets) {
|
|
553
|
+
initialState.mediaAssets = mediaAssets;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
453
557
|
return Object.keys(initialState).length > 0 ? initialState : undefined;
|
|
454
558
|
}
|
|
455
559
|
|