@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.
Files changed (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. 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("sizes intrinsic media apps from the imported image and replaces single-layer media", () => {
802
+ it("keeps the current editable-output canvas size when importing background/source images", () => {
634
803
  const app = defineToolcraft({
635
- canvas: { enabled: true, upload: true },
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 || state.mediaAssets.length === 0) {
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 mediaAssets = state.mediaAssets.filter((asset) => {
249
- if (asset.sourceTarget) {
250
- return !fileDropTargets.has(asset.sourceTarget);
251
- }
252
-
253
- return false;
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
- if (mediaAssets.length === state.mediaAssets.length) {
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
+ });