@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { defineToolcraft } from "../schema/define-toolcraft";
|
|
4
|
+
import type { ToolcraftInitialState } from "./types";
|
|
4
5
|
import { createToolcraftState } from "./create-template-state";
|
|
5
6
|
import { toolcraftReducer } from "./reducer";
|
|
6
7
|
|
|
7
|
-
function createState() {
|
|
8
|
+
function createState(initialState?: ToolcraftInitialState) {
|
|
8
9
|
const app = defineToolcraft({
|
|
9
10
|
canvas: {
|
|
10
11
|
enabled: true,
|
|
@@ -28,7 +29,7 @@ function createState() {
|
|
|
28
29
|
},
|
|
29
30
|
});
|
|
30
31
|
|
|
31
|
-
return createToolcraftState(app);
|
|
32
|
+
return createToolcraftState(app, initialState);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
describe("toolcraftReducer", () => {
|
|
@@ -147,6 +148,70 @@ describe("toolcraftReducer", () => {
|
|
|
147
148
|
});
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
it("restores predefined fileDrop media on global and section reset", () => {
|
|
152
|
+
const app = defineToolcraft({
|
|
153
|
+
canvas: { enabled: true, upload: true },
|
|
154
|
+
media: {
|
|
155
|
+
defaultAssets: [
|
|
156
|
+
{
|
|
157
|
+
dataUrl: "data:image/png;base64,AAAA",
|
|
158
|
+
fileName: "default-background.png",
|
|
159
|
+
id: "default-background",
|
|
160
|
+
layerId: "default-background-layer",
|
|
161
|
+
mimeType: "image/png",
|
|
162
|
+
size: { height: 1080, unit: "px", width: 1920 },
|
|
163
|
+
sourceTarget: "media.background",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
panels: {
|
|
168
|
+
controls: {
|
|
169
|
+
sections: [
|
|
170
|
+
{
|
|
171
|
+
controls: {
|
|
172
|
+
background: {
|
|
173
|
+
defaultValue: null,
|
|
174
|
+
target: "media.background",
|
|
175
|
+
type: "fileDrop",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
title: "Background",
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
title: "Controls",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const initial = createToolcraftState(app);
|
|
186
|
+
const deleted = toolcraftReducer(initial, {
|
|
187
|
+
mediaId: "default-background",
|
|
188
|
+
type: "media.delete",
|
|
189
|
+
});
|
|
190
|
+
const sectionReset = toolcraftReducer(deleted, {
|
|
191
|
+
label: "Reset Background section",
|
|
192
|
+
targets: ["media.background"],
|
|
193
|
+
type: "controls.resetTargets",
|
|
194
|
+
});
|
|
195
|
+
const deletedAgain = toolcraftReducer(sectionReset, {
|
|
196
|
+
mediaId: "default-background",
|
|
197
|
+
type: "media.delete",
|
|
198
|
+
});
|
|
199
|
+
const globalReset = toolcraftReducer(deletedAgain, { type: "controls.reset" });
|
|
200
|
+
|
|
201
|
+
expect(initial.mediaAssets.map((asset) => asset.id)).toEqual(["default-background"]);
|
|
202
|
+
expect(deleted.mediaAssets).toEqual([]);
|
|
203
|
+
expect(sectionReset.mediaAssets.map((asset) => asset.id)).toEqual([
|
|
204
|
+
"default-background",
|
|
205
|
+
]);
|
|
206
|
+
expect(sectionReset.layers.map((layer) => layer.id)).toEqual([
|
|
207
|
+
"default-background-layer",
|
|
208
|
+
]);
|
|
209
|
+
expect(sectionReset.selectedLayerId).toBe("default-background-layer");
|
|
210
|
+
expect(globalReset.mediaAssets.map((asset) => asset.id)).toEqual([
|
|
211
|
+
"default-background",
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
150
215
|
it("stores arbitrary fileDrop media without image dimensions and clears it on reset", () => {
|
|
151
216
|
const app = defineToolcraft({
|
|
152
217
|
canvas: { enabled: true, upload: true },
|
|
@@ -194,6 +259,64 @@ describe("toolcraftReducer", () => {
|
|
|
194
259
|
expect(state.mediaAssets).toEqual([]);
|
|
195
260
|
});
|
|
196
261
|
|
|
262
|
+
it("records image media rotate and flip transforms in runtime history", () => {
|
|
263
|
+
const imported = toolcraftReducer(createState(), {
|
|
264
|
+
asset: {
|
|
265
|
+
assetKind: "image",
|
|
266
|
+
dataUrl: "data:image/png;base64,test",
|
|
267
|
+
fileName: "source.png",
|
|
268
|
+
mimeType: "image/png",
|
|
269
|
+
position: { x: 0, y: 0 },
|
|
270
|
+
size: { height: 768, unit: "px", width: 1024 },
|
|
271
|
+
},
|
|
272
|
+
type: "media.import",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const rotated = toolcraftReducer(imported, {
|
|
276
|
+
mediaId: imported.mediaAssets[0]?.id ?? "",
|
|
277
|
+
operation: "rotate-right",
|
|
278
|
+
type: "media.transform",
|
|
279
|
+
});
|
|
280
|
+
const flipped = toolcraftReducer(rotated, {
|
|
281
|
+
mediaId: imported.mediaAssets[0]?.id ?? "",
|
|
282
|
+
operation: "flip-horizontal",
|
|
283
|
+
type: "media.transform",
|
|
284
|
+
});
|
|
285
|
+
const undone = toolcraftReducer(flipped, { type: "history.undo" });
|
|
286
|
+
|
|
287
|
+
expect(rotated.mediaAssets[0]?.transform).toEqual({ rotationDeg: 90 });
|
|
288
|
+
expect(flipped.mediaAssets[0]?.transform).toEqual({
|
|
289
|
+
flipHorizontal: true,
|
|
290
|
+
rotationDeg: 90,
|
|
291
|
+
});
|
|
292
|
+
expect(flipped.history.undo.at(-1)).toMatchObject({
|
|
293
|
+
before: { mediaAssets: rotated.mediaAssets },
|
|
294
|
+
label: "Transform media",
|
|
295
|
+
});
|
|
296
|
+
expect(undone.mediaAssets[0]?.transform).toEqual({ rotationDeg: 90 });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("ignores image transforms for arbitrary file media", () => {
|
|
300
|
+
const imported = toolcraftReducer(createState(), {
|
|
301
|
+
asset: {
|
|
302
|
+
assetKind: "file",
|
|
303
|
+
dataUrl: "data:text/plain;base64,aGVsbG8=",
|
|
304
|
+
fileName: "notes.txt",
|
|
305
|
+
mimeType: "text/plain",
|
|
306
|
+
position: { x: 0, y: 0 },
|
|
307
|
+
},
|
|
308
|
+
type: "media.import",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const transformed = toolcraftReducer(imported, {
|
|
312
|
+
mediaId: imported.mediaAssets[0]?.id ?? "",
|
|
313
|
+
operation: "rotate-right",
|
|
314
|
+
type: "media.transform",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(transformed).toBe(imported);
|
|
318
|
+
});
|
|
319
|
+
|
|
197
320
|
it("clears only selected fileDrop media on section reset", () => {
|
|
198
321
|
const app = defineToolcraft({
|
|
199
322
|
canvas: { enabled: true, upload: true },
|
|
@@ -554,6 +677,52 @@ describe("toolcraftReducer", () => {
|
|
|
554
677
|
expect(next.values).toBe(state.values);
|
|
555
678
|
});
|
|
556
679
|
|
|
680
|
+
it("stores timeline extended presentation outside control values", () => {
|
|
681
|
+
const state = createState();
|
|
682
|
+
|
|
683
|
+
const extended = toolcraftReducer(state, {
|
|
684
|
+
target: "panels.timeline.extended",
|
|
685
|
+
type: "controls.setValue",
|
|
686
|
+
value: true,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
expect(extended.panels.timeline.extended).toBe(true);
|
|
690
|
+
expect(extended.values).toBe(state.values);
|
|
691
|
+
expect(extended.history).toBe(state.history);
|
|
692
|
+
|
|
693
|
+
const compact = toolcraftReducer(extended, {
|
|
694
|
+
target: "panels.timeline.extended",
|
|
695
|
+
type: "controls.setValue",
|
|
696
|
+
value: false,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(compact.panels.timeline.extended).toBe(false);
|
|
700
|
+
expect(compact.values).toBe(state.values);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("keeps legacy timeline panel visibility outside control values", () => {
|
|
704
|
+
const state = createState();
|
|
705
|
+
|
|
706
|
+
const hidden = toolcraftReducer(state, {
|
|
707
|
+
target: "panels.timeline.visible",
|
|
708
|
+
type: "controls.setValue",
|
|
709
|
+
value: false,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect(hidden.panels.timeline.hidden).toBe(true);
|
|
713
|
+
expect(hidden.values).toBe(state.values);
|
|
714
|
+
expect(hidden.history).toBe(state.history);
|
|
715
|
+
|
|
716
|
+
const shown = toolcraftReducer(hidden, {
|
|
717
|
+
hidden: false,
|
|
718
|
+
panelId: "timeline",
|
|
719
|
+
type: "panels.setHidden",
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(shown.panels.timeline.hidden).toBe(false);
|
|
723
|
+
expect(shown.values).toBe(state.values);
|
|
724
|
+
});
|
|
725
|
+
|
|
557
726
|
it("resets a panel offset to its default position", () => {
|
|
558
727
|
const moved = toolcraftReducer(createState(), {
|
|
559
728
|
offset: { x: 40, y: 80 },
|
|
@@ -630,9 +799,85 @@ describe("toolcraftReducer", () => {
|
|
|
630
799
|
expect(next.history.undo.at(-1)?.label).toBe("Delete media");
|
|
631
800
|
});
|
|
632
801
|
|
|
633
|
-
it("
|
|
802
|
+
it("keeps the current editable-output canvas size when importing background/source images", () => {
|
|
634
803
|
const app = defineToolcraft({
|
|
635
|
-
canvas: {
|
|
804
|
+
canvas: {
|
|
805
|
+
enabled: true,
|
|
806
|
+
size: { height: 720, unit: "px", width: 1280 },
|
|
807
|
+
upload: true,
|
|
808
|
+
},
|
|
809
|
+
panels: {},
|
|
810
|
+
});
|
|
811
|
+
const state = createToolcraftState(app);
|
|
812
|
+
const imported = toolcraftReducer(state, {
|
|
813
|
+
asset: {
|
|
814
|
+
dataUrl: "data:image/png;base64,wide",
|
|
815
|
+
fileName: "wide-source.png",
|
|
816
|
+
mimeType: "image/png",
|
|
817
|
+
position: { x: 42, y: 24 },
|
|
818
|
+
size: { height: 1000, unit: "px", width: 4000 },
|
|
819
|
+
},
|
|
820
|
+
type: "media.import",
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
expect(imported.schema.canvas.sizing).toEqual({ mode: "editable-output" });
|
|
824
|
+
expect(imported.canvas.size).toEqual({ height: 720, unit: "px", width: 1280 });
|
|
825
|
+
expect(imported.mediaAssets[0]).toMatchObject({
|
|
826
|
+
fileName: "wide-source.png",
|
|
827
|
+
position: { x: 42, y: 24 },
|
|
828
|
+
size: { height: 1000, unit: "px", width: 4000 },
|
|
829
|
+
});
|
|
830
|
+
expect(imported.history.undo.at(-1)?.after).not.toHaveProperty("canvas.size");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("keeps manually edited editable-output canvas size when importing source images", () => {
|
|
834
|
+
const app = defineToolcraft({
|
|
835
|
+
canvas: {
|
|
836
|
+
enabled: true,
|
|
837
|
+
upload: true,
|
|
838
|
+
},
|
|
839
|
+
panels: {
|
|
840
|
+
controls: {
|
|
841
|
+
sections: [],
|
|
842
|
+
title: "Controls",
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
const widthChanged = toolcraftReducer(createToolcraftState(app), {
|
|
847
|
+
target: "canvas.size.width",
|
|
848
|
+
type: "controls.setValue",
|
|
849
|
+
value: "1440",
|
|
850
|
+
});
|
|
851
|
+
const heightChanged = toolcraftReducer(widthChanged, {
|
|
852
|
+
target: "canvas.size.height",
|
|
853
|
+
type: "controls.setValue",
|
|
854
|
+
value: "900",
|
|
855
|
+
});
|
|
856
|
+
const imported = toolcraftReducer(heightChanged, {
|
|
857
|
+
asset: {
|
|
858
|
+
dataUrl: "data:image/png;base64,tall",
|
|
859
|
+
fileName: "tall-source.png",
|
|
860
|
+
mimeType: "image/png",
|
|
861
|
+
position: { x: 0, y: 0 },
|
|
862
|
+
size: { height: 4000, unit: "px", width: 1000 },
|
|
863
|
+
},
|
|
864
|
+
type: "media.import",
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
expect(imported.schema.canvas.sizing).toEqual({ mode: "editable-output" });
|
|
868
|
+
expect(imported.canvas.size).toEqual({ height: 900, unit: "px", width: 1440 });
|
|
869
|
+
expect(imported.values["canvas.size.width"]).toBe(1440);
|
|
870
|
+
expect(imported.values["canvas.size.height"]).toBe(900);
|
|
871
|
+
expect(imported.mediaAssets[0]).toMatchObject({
|
|
872
|
+
fileName: "tall-source.png",
|
|
873
|
+
size: { height: 4000, unit: "px", width: 1000 },
|
|
874
|
+
});
|
|
875
|
+
expect(imported.history.undo.at(-1)?.after).not.toHaveProperty("canvas.size");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("sizes explicit intrinsic media apps from the imported image and replaces single-layer media", () => {
|
|
879
|
+
const app = defineToolcraft({
|
|
880
|
+
canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true },
|
|
636
881
|
panels: {},
|
|
637
882
|
});
|
|
638
883
|
const state = createToolcraftState(app);
|
|
@@ -1236,6 +1481,37 @@ describe("toolcraftReducer", () => {
|
|
|
1236
1481
|
expect(scrubbed.history.undo).toHaveLength(0);
|
|
1237
1482
|
});
|
|
1238
1483
|
|
|
1484
|
+
it("keeps loop state when timeline duration changes", () => {
|
|
1485
|
+
const state = toolcraftReducer(
|
|
1486
|
+
createState({
|
|
1487
|
+
timeline: {
|
|
1488
|
+
currentTimeSeconds: 9,
|
|
1489
|
+
durationSeconds: 10,
|
|
1490
|
+
isLooping: true,
|
|
1491
|
+
isPlaying: true,
|
|
1492
|
+
},
|
|
1493
|
+
}),
|
|
1494
|
+
{
|
|
1495
|
+
target: "selectedLayer.opacity",
|
|
1496
|
+
type: "controls.setValue",
|
|
1497
|
+
value: 42,
|
|
1498
|
+
},
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
const resized = toolcraftReducer(state, {
|
|
1502
|
+
durationSeconds: 6,
|
|
1503
|
+
type: "timeline.setDuration",
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
expect(resized.timeline.durationSeconds).toBe(6);
|
|
1507
|
+
expect(resized.timeline.currentTimeSeconds).toBe(6);
|
|
1508
|
+
expect(resized.timeline.isPlaying).toBe(true);
|
|
1509
|
+
expect(resized.timeline.isLooping).toBe(true);
|
|
1510
|
+
expect(resized.values).toEqual(state.values);
|
|
1511
|
+
expect(resized.values["selectedLayer.opacity"]).toBe(42);
|
|
1512
|
+
expect(resized.defaults).toEqual(state.defaults);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1239
1515
|
it("restarts playback from the beginning when play is pressed at the non-looping end", () => {
|
|
1240
1516
|
const paused = toolcraftReducer(createState(), { type: "timeline.togglePlayback" });
|
|
1241
1517
|
const loopDisabled = toolcraftReducer(paused, { type: "timeline.toggleLoop" });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getToolcraftCanvasSizeTargetDimension,
|
|
3
3
|
isToolcraftCanvasAspectRatioTarget,
|
|
4
|
+
isToolcraftTimelinePanelExtendedTarget,
|
|
5
|
+
isToolcraftTimelinePanelVisibleTarget,
|
|
4
6
|
} from "../schema/runtime-targets";
|
|
5
7
|
import { getToolcraftCanvasAspectRatioPreset } from "../schema/canvas-aspect-ratio-presets";
|
|
6
8
|
import {
|
|
@@ -8,6 +10,11 @@ import {
|
|
|
8
10
|
toolcraftCanvasZoomDefault,
|
|
9
11
|
toolcraftCanvasZoomStep,
|
|
10
12
|
} from "./canvas-zoom";
|
|
13
|
+
import {
|
|
14
|
+
cloneToolcraftLayers,
|
|
15
|
+
cloneToolcraftMediaAssets,
|
|
16
|
+
createToolcraftDefaultMediaState,
|
|
17
|
+
} from "./media-defaults";
|
|
11
18
|
import { getMediaReadyTimelineState } from "./timeline-readiness";
|
|
12
19
|
import type {
|
|
13
20
|
ToolcraftCommand,
|
|
@@ -15,6 +22,8 @@ import type {
|
|
|
15
22
|
ToolcraftHistoryMode,
|
|
16
23
|
ToolcraftLayer,
|
|
17
24
|
ToolcraftLayerDraft,
|
|
25
|
+
ToolcraftMediaAsset,
|
|
26
|
+
ToolcraftMediaTransform,
|
|
18
27
|
ToolcraftState,
|
|
19
28
|
ToolcraftTimelineKeyframe,
|
|
20
29
|
ToolcraftTimelineKeyframeGroup,
|
|
@@ -235,7 +244,7 @@ function getResetMediaPatch(
|
|
|
235
244
|
state: ToolcraftState,
|
|
236
245
|
targets?: ReadonlySet<string>,
|
|
237
246
|
): Pick<ToolcraftHistoryPatch, "after" | "before"> | null {
|
|
238
|
-
if (state.schema.panels.layers
|
|
247
|
+
if (state.schema.panels.layers) {
|
|
239
248
|
return null;
|
|
240
249
|
}
|
|
241
250
|
|
|
@@ -245,15 +254,46 @@ function getResetMediaPatch(
|
|
|
245
254
|
return null;
|
|
246
255
|
}
|
|
247
256
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
const defaultMediaState = createToolcraftDefaultMediaState(state.schema);
|
|
258
|
+
const defaultTargetMediaAssets = defaultMediaState.mediaAssets.filter((asset) =>
|
|
259
|
+
asset.sourceTarget ? fileDropTargets.has(asset.sourceTarget) : false,
|
|
260
|
+
);
|
|
261
|
+
const defaultTargetLayerIds = new Set(defaultTargetMediaAssets.map((asset) => asset.layerId));
|
|
262
|
+
const currentTargetLayerIds = new Set(
|
|
263
|
+
state.mediaAssets.flatMap((asset) =>
|
|
264
|
+
asset.sourceTarget && fileDropTargets.has(asset.sourceTarget) ? [asset.layerId] : [],
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
const mediaAssets = [
|
|
268
|
+
...state.mediaAssets.filter((asset) => {
|
|
269
|
+
if (asset.sourceTarget) {
|
|
270
|
+
return !fileDropTargets.has(asset.sourceTarget);
|
|
271
|
+
}
|
|
255
272
|
|
|
256
|
-
|
|
273
|
+
return false;
|
|
274
|
+
}),
|
|
275
|
+
...cloneToolcraftMediaAssets(defaultTargetMediaAssets),
|
|
276
|
+
];
|
|
277
|
+
const layers = [
|
|
278
|
+
...state.layers.filter(
|
|
279
|
+
(layer) => !currentTargetLayerIds.has(layer.id) && !defaultTargetLayerIds.has(layer.id),
|
|
280
|
+
),
|
|
281
|
+
...cloneToolcraftLayers(
|
|
282
|
+
defaultMediaState.layers.filter((layer) => defaultTargetLayerIds.has(layer.id)),
|
|
283
|
+
),
|
|
284
|
+
];
|
|
285
|
+
const selectedLayerId =
|
|
286
|
+
state.selectedLayerId && layers.some((layer) => layer.id === state.selectedLayerId)
|
|
287
|
+
? state.selectedLayerId
|
|
288
|
+
: (layers[0]?.id ?? null);
|
|
289
|
+
|
|
290
|
+
if (
|
|
291
|
+
mediaAssets.length === state.mediaAssets.length &&
|
|
292
|
+
mediaAssets.every((asset, index) => asset.id === state.mediaAssets[index]?.id) &&
|
|
293
|
+
layers.length === state.layers.length &&
|
|
294
|
+
layers.every((layer, index) => layer.id === state.layers[index]?.id) &&
|
|
295
|
+
selectedLayerId === state.selectedLayerId
|
|
296
|
+
) {
|
|
257
297
|
return null;
|
|
258
298
|
}
|
|
259
299
|
|
|
@@ -262,11 +302,15 @@ function getResetMediaPatch(
|
|
|
262
302
|
|
|
263
303
|
return {
|
|
264
304
|
after: {
|
|
305
|
+
layers,
|
|
265
306
|
mediaAssets,
|
|
307
|
+
selectedLayerId,
|
|
266
308
|
...(shouldCommitTimeline ? { timeline } : {}),
|
|
267
309
|
},
|
|
268
310
|
before: {
|
|
311
|
+
layers: state.layers,
|
|
269
312
|
mediaAssets: state.mediaAssets,
|
|
313
|
+
selectedLayerId: state.selectedLayerId,
|
|
270
314
|
...(shouldCommitTimeline ? { timeline: state.timeline } : {}),
|
|
271
315
|
},
|
|
272
316
|
};
|
|
@@ -533,6 +577,74 @@ function getImportedLayerName(fileName: string): string {
|
|
|
533
577
|
return name || "Material";
|
|
534
578
|
}
|
|
535
579
|
|
|
580
|
+
function normalizeMediaRotation(rotationDeg: number): 0 | 90 | 180 | 270 {
|
|
581
|
+
const normalized = ((Math.round(rotationDeg / 90) * 90) % 360 + 360) % 360;
|
|
582
|
+
|
|
583
|
+
return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function compactMediaTransform(
|
|
587
|
+
transform: ToolcraftMediaTransform,
|
|
588
|
+
): ToolcraftMediaTransform | undefined {
|
|
589
|
+
const normalizedRotation = normalizeMediaRotation(transform.rotationDeg ?? 0);
|
|
590
|
+
const nextTransform: ToolcraftMediaTransform = {};
|
|
591
|
+
|
|
592
|
+
if (normalizedRotation !== 0) {
|
|
593
|
+
nextTransform.rotationDeg = normalizedRotation;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (transform.flipHorizontal) {
|
|
597
|
+
nextTransform.flipHorizontal = true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (transform.flipVertical) {
|
|
601
|
+
nextTransform.flipVertical = true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return Object.keys(nextTransform).length > 0 ? nextTransform : undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function getTransformedMediaAsset(
|
|
608
|
+
mediaAsset: ToolcraftMediaAsset,
|
|
609
|
+
operation: Extract<ToolcraftCommand, { type: "media.transform" }>["operation"],
|
|
610
|
+
): ToolcraftMediaAsset {
|
|
611
|
+
const currentTransform = mediaAsset.transform ?? {};
|
|
612
|
+
const rotationDeg = normalizeMediaRotation(currentTransform.rotationDeg ?? 0);
|
|
613
|
+
const transform: ToolcraftMediaTransform = {
|
|
614
|
+
flipHorizontal: currentTransform.flipHorizontal,
|
|
615
|
+
flipVertical: currentTransform.flipVertical,
|
|
616
|
+
rotationDeg,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
switch (operation) {
|
|
620
|
+
case "flip-horizontal":
|
|
621
|
+
transform.flipHorizontal = !transform.flipHorizontal;
|
|
622
|
+
break;
|
|
623
|
+
case "flip-vertical":
|
|
624
|
+
transform.flipVertical = !transform.flipVertical;
|
|
625
|
+
break;
|
|
626
|
+
case "rotate-left":
|
|
627
|
+
transform.rotationDeg = normalizeMediaRotation(rotationDeg - 90);
|
|
628
|
+
break;
|
|
629
|
+
case "rotate-right":
|
|
630
|
+
transform.rotationDeg = normalizeMediaRotation(rotationDeg + 90);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const compactTransform = compactMediaTransform(transform);
|
|
635
|
+
|
|
636
|
+
if (!compactTransform) {
|
|
637
|
+
const { transform: _transform, ...rest } = mediaAsset;
|
|
638
|
+
|
|
639
|
+
return rest;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
...mediaAsset,
|
|
644
|
+
transform: compactTransform,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
536
648
|
function getNextLayerName(
|
|
537
649
|
layers: readonly ToolcraftLayer[],
|
|
538
650
|
prefix: "Group" | "Layer",
|
|
@@ -640,6 +752,44 @@ export function toolcraftReducer(
|
|
|
640
752
|
case "controls.setValue": {
|
|
641
753
|
const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(command.target);
|
|
642
754
|
|
|
755
|
+
if (isToolcraftTimelinePanelExtendedTarget(command.target)) {
|
|
756
|
+
const extended = command.value === true;
|
|
757
|
+
|
|
758
|
+
if (state.panels.timeline.extended === extended) {
|
|
759
|
+
return state;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
...state,
|
|
764
|
+
panels: {
|
|
765
|
+
...state.panels,
|
|
766
|
+
timeline: {
|
|
767
|
+
...state.panels.timeline,
|
|
768
|
+
extended,
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (isToolcraftTimelinePanelVisibleTarget(command.target)) {
|
|
775
|
+
const hidden = command.value === false;
|
|
776
|
+
|
|
777
|
+
if (state.panels.timeline.hidden === hidden) {
|
|
778
|
+
return state;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
...state,
|
|
783
|
+
panels: {
|
|
784
|
+
...state.panels,
|
|
785
|
+
timeline: {
|
|
786
|
+
...state.panels.timeline,
|
|
787
|
+
hidden,
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
643
793
|
if (isToolcraftCanvasAspectRatioTarget(command.target)) {
|
|
644
794
|
const ratio = normalizeCanvasAspectRatioValue(command.value, state.canvas.size);
|
|
645
795
|
const size =
|
|
@@ -1131,6 +1281,22 @@ export function toolcraftReducer(
|
|
|
1131
1281
|
},
|
|
1132
1282
|
};
|
|
1133
1283
|
|
|
1284
|
+
case "panels.setHidden":
|
|
1285
|
+
if (state.panels[command.panelId].hidden === command.hidden) {
|
|
1286
|
+
return state;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
...state,
|
|
1291
|
+
panels: {
|
|
1292
|
+
...state.panels,
|
|
1293
|
+
[command.panelId]: {
|
|
1294
|
+
...state.panels[command.panelId],
|
|
1295
|
+
hidden: command.hidden,
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1134
1300
|
case "panels.resetOffset":
|
|
1135
1301
|
return {
|
|
1136
1302
|
...state,
|
|
@@ -1288,6 +1454,26 @@ export function toolcraftReducer(
|
|
|
1288
1454
|
});
|
|
1289
1455
|
}
|
|
1290
1456
|
|
|
1457
|
+
case "media.transform": {
|
|
1458
|
+
const targetMediaAsset = state.mediaAssets.find((asset) => asset.id === command.mediaId);
|
|
1459
|
+
|
|
1460
|
+
if (!targetMediaAsset || (targetMediaAsset.assetKind ?? "image") !== "image") {
|
|
1461
|
+
return state;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const mediaAssets = state.mediaAssets.map((asset) =>
|
|
1465
|
+
asset.id === targetMediaAsset.id
|
|
1466
|
+
? getTransformedMediaAsset(asset, command.operation)
|
|
1467
|
+
: asset,
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
return commitStatePatch(state, {
|
|
1471
|
+
after: { mediaAssets },
|
|
1472
|
+
before: { mediaAssets: state.mediaAssets },
|
|
1473
|
+
label: "Transform media",
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1291
1477
|
case "timeline.setCurrentTime": {
|
|
1292
1478
|
return {
|
|
1293
1479
|
...state,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getToolcraftTimelineLoopProgress,
|
|
5
|
+
getToolcraftTimelineLoopTime,
|
|
6
|
+
} from "./timeline-loop";
|
|
7
|
+
|
|
8
|
+
describe("timeline loop helpers", () => {
|
|
9
|
+
it("wraps timeline time into a forward-only seamless loop", () => {
|
|
10
|
+
expect(
|
|
11
|
+
getToolcraftTimelineLoopTime({
|
|
12
|
+
currentTimeSeconds: 0,
|
|
13
|
+
durationSeconds: 8,
|
|
14
|
+
}),
|
|
15
|
+
).toBe(0);
|
|
16
|
+
expect(
|
|
17
|
+
getToolcraftTimelineLoopTime({
|
|
18
|
+
currentTimeSeconds: 7.5,
|
|
19
|
+
durationSeconds: 8,
|
|
20
|
+
}),
|
|
21
|
+
).toBe(7.5);
|
|
22
|
+
expect(
|
|
23
|
+
getToolcraftTimelineLoopTime({
|
|
24
|
+
currentTimeSeconds: 8,
|
|
25
|
+
durationSeconds: 8,
|
|
26
|
+
}),
|
|
27
|
+
).toBe(0);
|
|
28
|
+
expect(
|
|
29
|
+
getToolcraftTimelineLoopTime({
|
|
30
|
+
currentTimeSeconds: 9.25,
|
|
31
|
+
durationSeconds: 8,
|
|
32
|
+
}),
|
|
33
|
+
).toBe(1.25);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("maps one full product cycle to the current timeline duration", () => {
|
|
37
|
+
expect(
|
|
38
|
+
getToolcraftTimelineLoopProgress({
|
|
39
|
+
currentTimeSeconds: 2,
|
|
40
|
+
durationSeconds: 4,
|
|
41
|
+
}),
|
|
42
|
+
).toBe(0.5);
|
|
43
|
+
expect(
|
|
44
|
+
getToolcraftTimelineLoopProgress({
|
|
45
|
+
currentTimeSeconds: 2,
|
|
46
|
+
durationSeconds: 8,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(0.25);
|
|
49
|
+
expect(
|
|
50
|
+
getToolcraftTimelineLoopProgress({
|
|
51
|
+
currentTimeSeconds: 8,
|
|
52
|
+
durationSeconds: 8,
|
|
53
|
+
}),
|
|
54
|
+
).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles invalid timeline values as the first loop frame", () => {
|
|
58
|
+
expect(
|
|
59
|
+
getToolcraftTimelineLoopProgress({
|
|
60
|
+
currentTimeSeconds: Number.NaN,
|
|
61
|
+
durationSeconds: 8,
|
|
62
|
+
}),
|
|
63
|
+
).toBe(0);
|
|
64
|
+
expect(
|
|
65
|
+
getToolcraftTimelineLoopProgress({
|
|
66
|
+
currentTimeSeconds: 4,
|
|
67
|
+
durationSeconds: 0,
|
|
68
|
+
}),
|
|
69
|
+
).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|