@pixel-point/toolcraft 0.0.6 → 0.0.8

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/generate.test.mjs +1 -1
  3. package/templates/runtime/contracts/component-contracts.test.ts +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -95,6 +95,7 @@ export type ToolcraftAssemblyCommand =
95
95
  | "canvas.zoomReset"
96
96
  | "controls.apply"
97
97
  | "controls.reset"
98
+ | "controls.resetTargets"
98
99
  | "controls.setValue"
99
100
  | "history.redo"
100
101
  | "history.undo"
@@ -108,6 +109,7 @@ export type ToolcraftAssemblyCommand =
108
109
  | "layers.toggleVisibility"
109
110
  | "media.delete"
110
111
  | "media.import"
112
+ | "media.reorder"
111
113
  | "panels.resetOffset"
112
114
  | "panels.setOffset"
113
115
  | "timeline.changeKeyframeEasing"
@@ -272,6 +274,8 @@ export type ToolcraftControlPerformanceRole =
272
274
  | "responsiveness"
273
275
  | "workload";
274
276
 
277
+ export type ToolcraftFileDropAssetKind = "file" | "image";
278
+
275
279
  export type ToolcraftControlConditionSchema = {
276
280
  equals?: unknown;
277
281
  greaterThan?: number;
@@ -292,6 +296,20 @@ export type ToolcraftColorOpacityValueSchema = {
292
296
  opacity?: number;
293
297
  };
294
298
 
299
+ export type ToolcraftCollectionItemControlSchema = {
300
+ commitMode?: "content" | "setting";
301
+ defaultValue?: unknown;
302
+ label?: boolean | string;
303
+ markerCount?: number;
304
+ max?: number;
305
+ min?: number;
306
+ options?: readonly { label: string; value: string }[];
307
+ step?: number;
308
+ type: string;
309
+ unit?: string;
310
+ variant?: string;
311
+ };
312
+
295
313
  export type ToolcraftFontPickerValueSchema = {
296
314
  color?: string;
297
315
  fontId: string;
@@ -308,23 +326,32 @@ export type ToolcraftCurveInterpolation = "monotone" | "smooth";
308
326
  export type ToolcraftControlSchema = {
309
327
  accept?: string;
310
328
  actions?: readonly (ToolcraftActionSchema | string)[];
329
+ assetKind?: ToolcraftFileDropAssetKind;
330
+ addLabel?: string;
311
331
  commitMode?: "content" | "setting";
312
332
  defaultValue?: unknown;
313
333
  description?: string;
314
334
  disabled?: boolean;
315
335
  disabledWhen?: ToolcraftControlDisabledConditionSchema;
336
+ hardMaxItems?: number;
316
337
  interpolation?: ToolcraftCurveInterpolation;
317
338
  items?: readonly ToolcraftImagePickerItemSchema[];
339
+ itemControl?: ToolcraftCollectionItemControlSchema;
340
+ itemDefaultValue?: unknown;
341
+ itemLabel?: string;
318
342
  keyframeable?: boolean;
319
343
  label?: boolean | string;
320
344
  markerCount?: number;
321
345
  max?: number;
322
346
  min?: number;
347
+ minItems?: number;
323
348
  multiple?: boolean;
324
349
  orderRole?: ToolcraftControlOrderRole;
325
350
  performanceReason?: string;
326
351
  performanceRole?: ToolcraftControlPerformanceRole;
327
352
  options?: readonly { label: string; value: string }[];
353
+ recommendedMaxItems?: number;
354
+ removeLabel?: string;
328
355
  step?: number;
329
356
  target: string;
330
357
  type: string;
@@ -45,6 +45,260 @@ describe("toolcraftReducer", () => {
45
45
  expect(state.history.undo.at(-1)?.label).toBe("Reset controls");
46
46
  });
47
47
 
48
+ it("resets selected control targets to defaults and records one history patch", () => {
49
+ const app = defineToolcraft({
50
+ canvas: { enabled: false },
51
+ panels: {
52
+ controls: {
53
+ sections: [
54
+ {
55
+ controls: {
56
+ contrast: {
57
+ defaultValue: 22,
58
+ target: "style.contrast",
59
+ type: "slider",
60
+ },
61
+ opacity: {
62
+ defaultValue: 75,
63
+ target: "selectedLayer.opacity",
64
+ type: "slider",
65
+ },
66
+ },
67
+ title: "Tone",
68
+ },
69
+ ],
70
+ title: "Controls",
71
+ },
72
+ },
73
+ });
74
+ const changedOpacity = toolcraftReducer(createToolcraftState(app), {
75
+ target: "selectedLayer.opacity",
76
+ type: "controls.setValue",
77
+ value: 12,
78
+ });
79
+ const changedBoth = toolcraftReducer(changedOpacity, {
80
+ target: "style.contrast",
81
+ type: "controls.setValue",
82
+ value: 9,
83
+ });
84
+
85
+ const state = toolcraftReducer(changedBoth, {
86
+ label: "Reset Tone section",
87
+ targets: ["selectedLayer.opacity"],
88
+ type: "controls.resetTargets",
89
+ });
90
+
91
+ expect(state.values["selectedLayer.opacity"]).toBe(75);
92
+ expect(state.values["style.contrast"]).toBe(9);
93
+ expect(state.history.undo).toHaveLength(3);
94
+ expect(state.history.undo.at(-1)).toMatchObject({
95
+ after: { "selectedLayer.opacity": 75 },
96
+ before: { "selectedLayer.opacity": 12 },
97
+ label: "Reset Tone section",
98
+ });
99
+ });
100
+
101
+ it("clears single-layer fileDrop media on global reset", () => {
102
+ const app = defineToolcraft({
103
+ canvas: { enabled: true, upload: true },
104
+ panels: {
105
+ controls: {
106
+ sections: [
107
+ {
108
+ controls: {
109
+ source: {
110
+ defaultValue: null,
111
+ target: "media.source",
112
+ type: "fileDrop",
113
+ },
114
+ },
115
+ title: "Source",
116
+ },
117
+ ],
118
+ title: "Controls",
119
+ },
120
+ },
121
+ });
122
+ const imported = toolcraftReducer(createToolcraftState(app), {
123
+ asset: {
124
+ dataUrl: "data:image/png;base64,test",
125
+ fileName: "source.png",
126
+ mimeType: "image/png",
127
+ position: { x: 0, y: 0 },
128
+ size: { height: 768, unit: "px", width: 1024 },
129
+ sourceTarget: "media.source",
130
+ },
131
+ type: "media.import",
132
+ });
133
+
134
+ const state = toolcraftReducer(imported, { type: "controls.reset" });
135
+ const undone = toolcraftReducer(state, { type: "history.undo" });
136
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
137
+
138
+ expect(imported.mediaAssets).toHaveLength(1);
139
+ expect(state.mediaAssets).toEqual([]);
140
+ expect(state.values["media.source"]).toBeNull();
141
+ expect(undone.mediaAssets).toEqual(imported.mediaAssets);
142
+ expect(redone.mediaAssets).toEqual([]);
143
+ expect(state.history.undo.at(-1)).toMatchObject({
144
+ after: { mediaAssets: [] },
145
+ before: { mediaAssets: imported.mediaAssets },
146
+ label: "Reset controls",
147
+ });
148
+ });
149
+
150
+ it("stores arbitrary fileDrop media without image dimensions and clears it on reset", () => {
151
+ const app = defineToolcraft({
152
+ canvas: { enabled: true, upload: true },
153
+ panels: {
154
+ controls: {
155
+ sections: [
156
+ {
157
+ controls: {
158
+ source: {
159
+ assetKind: "file",
160
+ defaultValue: null,
161
+ target: "files.source",
162
+ type: "fileDrop",
163
+ },
164
+ },
165
+ title: "Files",
166
+ },
167
+ ],
168
+ title: "Controls",
169
+ },
170
+ },
171
+ });
172
+ const imported = toolcraftReducer(createToolcraftState(app), {
173
+ asset: {
174
+ assetKind: "file",
175
+ dataUrl: "data:text/plain;base64,aGVsbG8=",
176
+ fileName: "notes.txt",
177
+ mimeType: "text/plain",
178
+ position: { x: 0, y: 0 },
179
+ sourceTarget: "files.source",
180
+ },
181
+ type: "media.import",
182
+ });
183
+
184
+ const state = toolcraftReducer(imported, { type: "controls.reset" });
185
+
186
+ expect(imported.mediaAssets).toHaveLength(1);
187
+ expect(imported.mediaAssets[0]).toMatchObject({
188
+ assetKind: "file",
189
+ fileName: "notes.txt",
190
+ sourceTarget: "files.source",
191
+ });
192
+ expect(imported.mediaAssets[0]?.size).toBeUndefined();
193
+ expect(imported.canvas.size).toEqual(createToolcraftState(app).canvas.size);
194
+ expect(state.mediaAssets).toEqual([]);
195
+ });
196
+
197
+ it("clears only selected fileDrop media on section reset", () => {
198
+ const app = defineToolcraft({
199
+ canvas: { enabled: true, upload: true },
200
+ panels: {
201
+ controls: {
202
+ sections: [
203
+ {
204
+ controls: {
205
+ primary: {
206
+ defaultValue: null,
207
+ target: "media.primary",
208
+ type: "fileDrop",
209
+ },
210
+ secondary: {
211
+ defaultValue: null,
212
+ target: "media.secondary",
213
+ type: "fileDrop",
214
+ },
215
+ },
216
+ title: "Sources",
217
+ },
218
+ ],
219
+ title: "Controls",
220
+ },
221
+ },
222
+ });
223
+ const first = toolcraftReducer(createToolcraftState(app), {
224
+ asset: {
225
+ dataUrl: "data:image/png;base64,primary",
226
+ fileName: "primary.png",
227
+ mimeType: "image/png",
228
+ position: { x: 0, y: 0 },
229
+ size: { height: 768, unit: "px", width: 1024 },
230
+ sourceTarget: "media.primary",
231
+ },
232
+ replaceExisting: false,
233
+ type: "media.import",
234
+ });
235
+ const second = toolcraftReducer(first, {
236
+ asset: {
237
+ dataUrl: "data:image/png;base64,secondary",
238
+ fileName: "secondary.png",
239
+ mimeType: "image/png",
240
+ position: { x: 0, y: 0 },
241
+ size: { height: 768, unit: "px", width: 1024 },
242
+ sourceTarget: "media.secondary",
243
+ },
244
+ replaceExisting: false,
245
+ type: "media.import",
246
+ });
247
+
248
+ const state = toolcraftReducer(second, {
249
+ label: "Reset Primary section",
250
+ targets: ["media.primary"],
251
+ type: "controls.resetTargets",
252
+ });
253
+
254
+ expect(state.mediaAssets).toHaveLength(1);
255
+ expect(state.mediaAssets[0]?.fileName).toBe("secondary.png");
256
+ expect(state.history.undo.at(-1)).toMatchObject({
257
+ after: { mediaAssets: state.mediaAssets },
258
+ before: { mediaAssets: second.mediaAssets },
259
+ label: "Reset Primary section",
260
+ });
261
+ });
262
+
263
+ it("keeps layer-owned media on global reset", () => {
264
+ const app = defineToolcraft({
265
+ canvas: { enabled: true, upload: true },
266
+ panels: {
267
+ controls: {
268
+ sections: [
269
+ {
270
+ controls: {
271
+ source: {
272
+ defaultValue: null,
273
+ target: "media.source",
274
+ type: "fileDrop",
275
+ },
276
+ },
277
+ title: "Source",
278
+ },
279
+ ],
280
+ title: "Controls",
281
+ },
282
+ layers: true,
283
+ },
284
+ });
285
+ const imported = toolcraftReducer(createToolcraftState(app), {
286
+ asset: {
287
+ dataUrl: "data:image/png;base64,test",
288
+ fileName: "source.png",
289
+ mimeType: "image/png",
290
+ position: { x: 0, y: 0 },
291
+ size: { height: 768, unit: "px", width: 1024 },
292
+ sourceTarget: "media.source",
293
+ },
294
+ type: "media.import",
295
+ });
296
+
297
+ const state = toolcraftReducer(imported, { type: "controls.reset" });
298
+
299
+ expect(state.mediaAssets).toEqual(imported.mediaAssets);
300
+ });
301
+
48
302
  it("updates canvas size and records history", () => {
49
303
  const size = { width: 1200, height: 900, unit: "px" } as const;
50
304
 
@@ -458,6 +712,109 @@ describe("toolcraftReducer", () => {
458
712
  expect(second.selectedLayerId).toBe("layer-2");
459
713
  });
460
714
 
715
+ it("reorders imported media assets and records undo history", () => {
716
+ const state = createState();
717
+ const first = toolcraftReducer(state, {
718
+ asset: {
719
+ dataUrl: "data:image/png;base64,first",
720
+ fileName: "first.png",
721
+ mimeType: "image/png",
722
+ position: { x: 0, y: 0 },
723
+ size: state.canvas.size,
724
+ },
725
+ replaceExisting: false,
726
+ type: "media.import",
727
+ });
728
+ const second = toolcraftReducer(first, {
729
+ asset: {
730
+ dataUrl: "data:image/png;base64,second",
731
+ fileName: "second.png",
732
+ mimeType: "image/png",
733
+ position: { x: 0, y: 0 },
734
+ size: state.canvas.size,
735
+ },
736
+ replaceExisting: false,
737
+ type: "media.import",
738
+ });
739
+ const third = toolcraftReducer(second, {
740
+ asset: {
741
+ dataUrl: "data:image/png;base64,third",
742
+ fileName: "third.png",
743
+ mimeType: "image/png",
744
+ position: { x: 0, y: 0 },
745
+ size: state.canvas.size,
746
+ },
747
+ replaceExisting: false,
748
+ type: "media.import",
749
+ });
750
+
751
+ const reordered = toolcraftReducer(third, {
752
+ mediaIds: ["media-3", "media-1", "media-2"],
753
+ type: "media.reorder",
754
+ });
755
+ const undone = toolcraftReducer(reordered, { type: "history.undo" });
756
+
757
+ expect(reordered.mediaAssets.map((asset) => asset.id)).toEqual([
758
+ "media-3",
759
+ "media-1",
760
+ "media-2",
761
+ ]);
762
+ expect(reordered.history.undo.at(-1)?.label).toBe("Reorder media");
763
+ expect(undone.mediaAssets.map((asset) => asset.id)).toEqual([
764
+ "media-1",
765
+ "media-2",
766
+ "media-3",
767
+ ]);
768
+ });
769
+
770
+ it("keeps omitted media assets after explicitly reordered media", () => {
771
+ const state = createState();
772
+ const first = toolcraftReducer(state, {
773
+ asset: {
774
+ dataUrl: "data:image/png;base64,first",
775
+ fileName: "first.png",
776
+ mimeType: "image/png",
777
+ position: { x: 0, y: 0 },
778
+ size: state.canvas.size,
779
+ },
780
+ replaceExisting: false,
781
+ type: "media.import",
782
+ });
783
+ const second = toolcraftReducer(first, {
784
+ asset: {
785
+ dataUrl: "data:image/png;base64,second",
786
+ fileName: "second.png",
787
+ mimeType: "image/png",
788
+ position: { x: 0, y: 0 },
789
+ size: state.canvas.size,
790
+ },
791
+ replaceExisting: false,
792
+ type: "media.import",
793
+ });
794
+ const third = toolcraftReducer(second, {
795
+ asset: {
796
+ dataUrl: "data:image/png;base64,third",
797
+ fileName: "third.png",
798
+ mimeType: "image/png",
799
+ position: { x: 0, y: 0 },
800
+ size: state.canvas.size,
801
+ },
802
+ replaceExisting: false,
803
+ type: "media.import",
804
+ });
805
+
806
+ const reordered = toolcraftReducer(third, {
807
+ mediaIds: ["media-2"],
808
+ type: "media.reorder",
809
+ });
810
+
811
+ expect(reordered.mediaAssets.map((asset) => asset.id)).toEqual([
812
+ "media-2",
813
+ "media-1",
814
+ "media-3",
815
+ ]);
816
+ });
817
+
461
818
  it("adds and selects runtime layers and groups", () => {
462
819
  const withGroup = toolcraftReducer(createState(), {
463
820
  layer: {
@@ -212,6 +212,77 @@ function getResetCanvasSize(
212
212
  };
213
213
  }
214
214
 
215
+ function getFileDropResetTargets(
216
+ state: ToolcraftState,
217
+ targets?: ReadonlySet<string>,
218
+ ): Set<string> {
219
+ const fileDropTargets = new Set<string>();
220
+
221
+ for (const section of state.schema.panels.controls?.sections ?? []) {
222
+ for (const control of Object.values(section.controls)) {
223
+ if (control.type !== "fileDrop" || (targets && !targets.has(control.target))) {
224
+ continue;
225
+ }
226
+
227
+ fileDropTargets.add(control.target);
228
+ }
229
+ }
230
+
231
+ return fileDropTargets;
232
+ }
233
+
234
+ function getResetMediaPatch(
235
+ state: ToolcraftState,
236
+ targets?: ReadonlySet<string>,
237
+ ): Pick<ToolcraftHistoryPatch, "after" | "before"> | null {
238
+ if (state.schema.panels.layers || state.mediaAssets.length === 0) {
239
+ return null;
240
+ }
241
+
242
+ const fileDropTargets = getFileDropResetTargets(state, targets);
243
+
244
+ if (fileDropTargets.size === 0) {
245
+ return null;
246
+ }
247
+
248
+ const mediaAssets = state.mediaAssets.filter((asset) => {
249
+ if (asset.sourceTarget) {
250
+ return !fileDropTargets.has(asset.sourceTarget);
251
+ }
252
+
253
+ return false;
254
+ });
255
+
256
+ if (mediaAssets.length === state.mediaAssets.length) {
257
+ return null;
258
+ }
259
+
260
+ const timeline = getMediaReadyTimelineState(state.schema, state.timeline, mediaAssets);
261
+ const shouldCommitTimeline = timeline !== state.timeline;
262
+
263
+ return {
264
+ after: {
265
+ mediaAssets,
266
+ ...(shouldCommitTimeline ? { timeline } : {}),
267
+ },
268
+ before: {
269
+ mediaAssets: state.mediaAssets,
270
+ ...(shouldCommitTimeline ? { timeline: state.timeline } : {}),
271
+ },
272
+ };
273
+ }
274
+
275
+ function canvasSizesEqual(
276
+ first: ToolcraftState["canvas"]["size"],
277
+ second: ToolcraftState["canvas"]["size"],
278
+ ): boolean {
279
+ return (
280
+ first.height === second.height &&
281
+ first.unit === second.unit &&
282
+ first.width === second.width
283
+ );
284
+ }
285
+
215
286
  function clampTimelineDuration(value: number): number {
216
287
  if (!Number.isFinite(value)) {
217
288
  return minTimelineDurationSeconds;
@@ -686,16 +757,19 @@ export function toolcraftReducer(
686
757
 
687
758
  case "controls.reset": {
688
759
  const resetCanvasSize = getResetCanvasSize(state);
760
+ const resetMediaPatch = getResetMediaPatch(state);
689
761
 
690
- if (resetCanvasSize) {
762
+ if (resetCanvasSize || resetMediaPatch) {
691
763
  return commitStatePatch(state, {
692
764
  after: {
693
765
  ...state.defaults,
694
- "canvas.size": resetCanvasSize,
766
+ ...(resetCanvasSize ? { "canvas.size": resetCanvasSize } : {}),
767
+ ...resetMediaPatch?.after,
695
768
  },
696
769
  before: {
697
770
  ...state.values,
698
- "canvas.size": state.canvas.size,
771
+ ...(resetCanvasSize ? { "canvas.size": state.canvas.size } : {}),
772
+ ...resetMediaPatch?.before,
699
773
  },
700
774
  label: "Reset controls",
701
775
  });
@@ -712,6 +786,48 @@ export function toolcraftReducer(
712
786
  );
713
787
  }
714
788
 
789
+ case "controls.resetTargets": {
790
+ const targetSet = new Set(command.targets);
791
+ const before: Record<string, unknown> = {};
792
+ const after: Record<string, unknown> = {};
793
+ const resetMediaPatch = getResetMediaPatch(state, targetSet);
794
+
795
+ for (const target of targetSet) {
796
+ if (!(target in state.defaults) || Object.is(state.values[target], state.defaults[target])) {
797
+ continue;
798
+ }
799
+
800
+ before[target] = state.values[target];
801
+ after[target] = state.defaults[target];
802
+ }
803
+
804
+ const resetCanvasSize = getResetCanvasSize(state);
805
+ const shouldResetCanvasSize =
806
+ resetCanvasSize !== null &&
807
+ (targetSet.has(canvasSizeWidthTarget) || targetSet.has(canvasSizeHeightTarget)) &&
808
+ !canvasSizesEqual(state.canvas.size, resetCanvasSize);
809
+
810
+ if (shouldResetCanvasSize) {
811
+ before["canvas.size"] = state.canvas.size;
812
+ after["canvas.size"] = resetCanvasSize;
813
+ }
814
+
815
+ if (resetMediaPatch) {
816
+ Object.assign(before, resetMediaPatch.before);
817
+ Object.assign(after, resetMediaPatch.after);
818
+ }
819
+
820
+ if (Object.keys(after).length === 0) {
821
+ return state;
822
+ }
823
+
824
+ return commitStatePatch(state, {
825
+ after,
826
+ before,
827
+ label: command.label ?? "Reset section",
828
+ });
829
+ }
830
+
715
831
  case "layers.add": {
716
832
  const layer = createLayer(state, command.layer);
717
833
  const insertIndex = clampInsertIndex(state.layers.length, command.insertIndex);
@@ -1030,15 +1146,29 @@ export function toolcraftReducer(
1030
1146
  case "media.import": {
1031
1147
  const shouldReplaceSingleLayerMedia =
1032
1148
  !state.schema.panels.layers && command.replaceExisting !== false;
1149
+ const assetKind = command.asset.assetKind ?? "image";
1150
+ const sourceTarget = command.asset.sourceTarget;
1151
+ const existingSourceMediaAsset =
1152
+ shouldReplaceSingleLayerMedia && sourceTarget
1153
+ ? state.mediaAssets.find((asset) => asset.sourceTarget === sourceTarget)
1154
+ : undefined;
1033
1155
  const shouldResizeCanvas =
1034
- state.schema.canvas.sizing.mode === "intrinsic-media";
1156
+ state.schema.canvas.sizing.mode === "intrinsic-media" &&
1157
+ assetKind === "image" &&
1158
+ command.asset.size !== undefined;
1035
1159
  const layerId =
1036
1160
  command.asset.layerId ??
1037
- (shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
1161
+ existingSourceMediaAsset?.layerId ??
1162
+ (shouldReplaceSingleLayerMedia && !sourceTarget
1163
+ ? getSingleLayerImportId(state)
1164
+ : undefined) ??
1038
1165
  getNextLayerId(state);
1039
1166
  const mediaId =
1040
1167
  command.asset.id ??
1041
- (shouldReplaceSingleLayerMedia ? getSingleMediaImportId(state) : undefined) ??
1168
+ existingSourceMediaAsset?.id ??
1169
+ (shouldReplaceSingleLayerMedia && !sourceTarget
1170
+ ? getSingleMediaImportId(state)
1171
+ : undefined) ??
1042
1172
  getNextMediaId(state);
1043
1173
  const layer = {
1044
1174
  displayName: command.asset.layerName ?? getImportedLayerName(command.asset.fileName),
@@ -1048,17 +1178,31 @@ export function toolcraftReducer(
1048
1178
  visible: true,
1049
1179
  };
1050
1180
  const mediaAsset = {
1181
+ ...(command.asset.assetKind ? { assetKind: command.asset.assetKind } : {}),
1051
1182
  dataUrl: command.asset.dataUrl,
1052
1183
  fileName: command.asset.fileName,
1053
1184
  id: mediaId,
1054
1185
  layerId,
1055
1186
  mimeType: command.asset.mimeType,
1056
1187
  position: shouldResizeCanvas ? { x: 0, y: 0 } : command.asset.position,
1057
- size: command.asset.size,
1188
+ ...(command.asset.size ? { size: command.asset.size } : {}),
1189
+ ...(sourceTarget ? { sourceTarget } : {}),
1058
1190
  };
1059
- const layers = shouldReplaceSingleLayerMedia ? [layer] : [...state.layers, layer];
1191
+ const layers = shouldReplaceSingleLayerMedia
1192
+ ? sourceTarget
1193
+ ? state.layers.some((entry) => entry.id === layerId)
1194
+ ? state.layers.map((entry) => (entry.id === layerId ? layer : entry))
1195
+ : [...state.layers, layer]
1196
+ : [layer]
1197
+ : [...state.layers, layer];
1060
1198
  const mediaAssets = shouldReplaceSingleLayerMedia
1061
- ? [mediaAsset]
1199
+ ? sourceTarget
1200
+ ? existingSourceMediaAsset
1201
+ ? state.mediaAssets.map((asset) =>
1202
+ asset.id === existingSourceMediaAsset.id ? mediaAsset : asset,
1203
+ )
1204
+ : [...state.mediaAssets, mediaAsset]
1205
+ : [mediaAsset]
1062
1206
  : [...state.mediaAssets, mediaAsset];
1063
1207
  const after = {
1064
1208
  ...(shouldResizeCanvas ? { "canvas.size": command.asset.size } : {}),
@@ -1102,6 +1246,48 @@ export function toolcraftReducer(
1102
1246
  });
1103
1247
  }
1104
1248
 
1249
+ case "media.reorder": {
1250
+ if (state.mediaAssets.length < 2 || command.mediaIds.length === 0) {
1251
+ return state;
1252
+ }
1253
+
1254
+ const mediaById = new Map(state.mediaAssets.map((asset) => [asset.id, asset]));
1255
+ const seenIds = new Set<string>();
1256
+ const reorderedMediaAssets = command.mediaIds.flatMap((mediaId) => {
1257
+ const mediaAsset = mediaById.get(mediaId);
1258
+
1259
+ if (!mediaAsset || seenIds.has(mediaId)) {
1260
+ return [];
1261
+ }
1262
+
1263
+ seenIds.add(mediaId);
1264
+ return [mediaAsset];
1265
+ });
1266
+
1267
+ if (reorderedMediaAssets.length === 0) {
1268
+ return state;
1269
+ }
1270
+
1271
+ for (const mediaAsset of state.mediaAssets) {
1272
+ if (!seenIds.has(mediaAsset.id)) {
1273
+ reorderedMediaAssets.push(mediaAsset);
1274
+ }
1275
+ }
1276
+
1277
+ if (
1278
+ reorderedMediaAssets.length === state.mediaAssets.length &&
1279
+ reorderedMediaAssets.every((asset, index) => asset.id === state.mediaAssets[index]?.id)
1280
+ ) {
1281
+ return state;
1282
+ }
1283
+
1284
+ return commitStatePatch(state, {
1285
+ after: { mediaAssets: reorderedMediaAssets },
1286
+ before: { mediaAssets: state.mediaAssets },
1287
+ label: "Reorder media",
1288
+ });
1289
+ }
1290
+
1105
1291
  case "timeline.setCurrentTime": {
1106
1292
  return {
1107
1293
  ...state,