@pixel-point/toolcraft 0.0.7 → 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 (41) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +84 -12
  3. package/templates/runtime/contracts/component-contracts.ts +40 -17
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +31 -0
  6. package/templates/runtime/export/export.ts +41 -0
  7. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  8. package/templates/runtime/react/canvas-shell.tsx +178 -23
  9. package/templates/runtime/react/control-conditions.ts +166 -0
  10. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  11. package/templates/runtime/react/controls-panel.test.tsx +404 -8
  12. package/templates/runtime/react/controls-panel.tsx +90 -4
  13. package/templates/runtime/react/media-file.ts +19 -0
  14. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  15. package/templates/runtime/schema/define-toolcraft.ts +4 -2
  16. package/templates/runtime/schema/types.ts +4 -0
  17. package/templates/runtime/state/reducer.test.ts +304 -0
  18. package/templates/runtime/state/reducer.ts +148 -9
  19. package/templates/runtime/state/types.ts +10 -2
  20. package/templates/runtime/testing/performance.test.ts +1282 -48
  21. package/templates/runtime/testing/performance.ts +676 -39
  22. package/templates/starter/AGENTS.md +6 -5
  23. package/templates/starter/docs/toolcraft/acceptance-testing.md +11 -5
  24. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  25. package/templates/starter/docs/toolcraft/component-rules.md +16 -7
  26. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  27. package/templates/starter/docs/toolcraft/performance.md +47 -5
  28. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  29. package/templates/starter/docs/toolcraft/schema-reference.md +6 -6
  30. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  31. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  32. package/templates/starter/e2e/performance-helpers.ts +197 -0
  33. package/templates/starter/package.json +3 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  35. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  36. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  37. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  38. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  39. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  40. package/templates/ui/components/controls/index.ts +1 -0
  41. package/templates/ui/components/panel/panel-section.tsx +53 -1
@@ -5,6 +5,10 @@ export type ToolcraftImportedImageFile = {
5
5
  size: ToolcraftCanvasSize;
6
6
  };
7
7
 
8
+ export type ToolcraftImportedFile = {
9
+ dataUrl: string;
10
+ };
11
+
8
12
  function readFileDataUrl(file: File): Promise<string | null> {
9
13
  return new Promise((resolve) => {
10
14
  const reader = new FileReader();
@@ -65,6 +69,21 @@ function readImageDataUrlSize(dataUrl: string): Promise<ToolcraftCanvasSize | nu
65
69
  });
66
70
  }
67
71
 
72
+ export function isToolcraftImageFile(file: File): boolean {
73
+ return (
74
+ file.type.startsWith("image/") ||
75
+ /\.(avif|gif|heic|heif|jpe?g|png|svg|tiff?|webp)$/i.test(file.name)
76
+ );
77
+ }
78
+
79
+ export async function readImportedFile(
80
+ file: File,
81
+ ): Promise<ToolcraftImportedFile | null> {
82
+ const dataUrl = await readFileDataUrl(file);
83
+
84
+ return dataUrl ? { dataUrl } : null;
85
+ }
86
+
68
87
  export async function readImportedImageFile(
69
88
  file: File,
70
89
  fallbackSize: ToolcraftCanvasSize,
@@ -539,6 +539,7 @@ describe("defineToolcraft", () => {
539
539
  "history.undo",
540
540
  "media.delete",
541
541
  "media.import",
542
+ "media.reorder",
542
543
  "layers.reorder",
543
544
  "timeline.setCurrentTime",
544
545
  "canvas.center",
@@ -332,7 +332,7 @@ function createToolcraftAssembly({
332
332
 
333
333
  if (canvas.upload) {
334
334
  capabilities.push("canvas.upload");
335
- commands.push("media.delete", "media.import");
335
+ commands.push("media.delete", "media.import", "media.reorder");
336
336
  }
337
337
  }
338
338
 
@@ -478,7 +478,9 @@ function createToolcraftAssembly({
478
478
  ...(canvas.draggable
479
479
  ? (["canvas.panBy", "canvas.setOffset", "canvas.setViewport"] as const)
480
480
  : []),
481
- ...(canvas.upload ? (["media.delete", "media.import"] as const) : []),
481
+ ...(canvas.upload
482
+ ? (["media.delete", "media.import", "media.reorder"] as const)
483
+ : []),
482
484
  ])
483
485
  : [],
484
486
  enabled: canvas.enabled,
@@ -109,6 +109,7 @@ export type ToolcraftAssemblyCommand =
109
109
  | "layers.toggleVisibility"
110
110
  | "media.delete"
111
111
  | "media.import"
112
+ | "media.reorder"
112
113
  | "panels.resetOffset"
113
114
  | "panels.setOffset"
114
115
  | "timeline.changeKeyframeEasing"
@@ -273,6 +274,8 @@ export type ToolcraftControlPerformanceRole =
273
274
  | "responsiveness"
274
275
  | "workload";
275
276
 
277
+ export type ToolcraftFileDropAssetKind = "file" | "image";
278
+
276
279
  export type ToolcraftControlConditionSchema = {
277
280
  equals?: unknown;
278
281
  greaterThan?: number;
@@ -323,6 +326,7 @@ export type ToolcraftCurveInterpolation = "monotone" | "smooth";
323
326
  export type ToolcraftControlSchema = {
324
327
  accept?: string;
325
328
  actions?: readonly (ToolcraftActionSchema | string)[];
329
+ assetKind?: ToolcraftFileDropAssetKind;
326
330
  addLabel?: string;
327
331
  commitMode?: "content" | "setting";
328
332
  defaultValue?: unknown;
@@ -98,6 +98,207 @@ describe("toolcraftReducer", () => {
98
98
  });
99
99
  });
100
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
+
101
302
  it("updates canvas size and records history", () => {
102
303
  const size = { width: 1200, height: 900, unit: "px" } as const;
103
304
 
@@ -511,6 +712,109 @@ describe("toolcraftReducer", () => {
511
712
  expect(second.selectedLayerId).toBe("layer-2");
512
713
  });
513
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
+
514
818
  it("adds and selects runtime layers and groups", () => {
515
819
  const withGroup = toolcraftReducer(createState(), {
516
820
  layer: {
@@ -212,6 +212,66 @@ 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
+
215
275
  function canvasSizesEqual(
216
276
  first: ToolcraftState["canvas"]["size"],
217
277
  second: ToolcraftState["canvas"]["size"],
@@ -697,16 +757,19 @@ export function toolcraftReducer(
697
757
 
698
758
  case "controls.reset": {
699
759
  const resetCanvasSize = getResetCanvasSize(state);
760
+ const resetMediaPatch = getResetMediaPatch(state);
700
761
 
701
- if (resetCanvasSize) {
762
+ if (resetCanvasSize || resetMediaPatch) {
702
763
  return commitStatePatch(state, {
703
764
  after: {
704
765
  ...state.defaults,
705
- "canvas.size": resetCanvasSize,
766
+ ...(resetCanvasSize ? { "canvas.size": resetCanvasSize } : {}),
767
+ ...resetMediaPatch?.after,
706
768
  },
707
769
  before: {
708
770
  ...state.values,
709
- "canvas.size": state.canvas.size,
771
+ ...(resetCanvasSize ? { "canvas.size": state.canvas.size } : {}),
772
+ ...resetMediaPatch?.before,
710
773
  },
711
774
  label: "Reset controls",
712
775
  });
@@ -727,6 +790,7 @@ export function toolcraftReducer(
727
790
  const targetSet = new Set(command.targets);
728
791
  const before: Record<string, unknown> = {};
729
792
  const after: Record<string, unknown> = {};
793
+ const resetMediaPatch = getResetMediaPatch(state, targetSet);
730
794
 
731
795
  for (const target of targetSet) {
732
796
  if (!(target in state.defaults) || Object.is(state.values[target], state.defaults[target])) {
@@ -748,6 +812,11 @@ export function toolcraftReducer(
748
812
  after["canvas.size"] = resetCanvasSize;
749
813
  }
750
814
 
815
+ if (resetMediaPatch) {
816
+ Object.assign(before, resetMediaPatch.before);
817
+ Object.assign(after, resetMediaPatch.after);
818
+ }
819
+
751
820
  if (Object.keys(after).length === 0) {
752
821
  return state;
753
822
  }
@@ -1077,15 +1146,29 @@ export function toolcraftReducer(
1077
1146
  case "media.import": {
1078
1147
  const shouldReplaceSingleLayerMedia =
1079
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;
1080
1155
  const shouldResizeCanvas =
1081
- state.schema.canvas.sizing.mode === "intrinsic-media";
1156
+ state.schema.canvas.sizing.mode === "intrinsic-media" &&
1157
+ assetKind === "image" &&
1158
+ command.asset.size !== undefined;
1082
1159
  const layerId =
1083
1160
  command.asset.layerId ??
1084
- (shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
1161
+ existingSourceMediaAsset?.layerId ??
1162
+ (shouldReplaceSingleLayerMedia && !sourceTarget
1163
+ ? getSingleLayerImportId(state)
1164
+ : undefined) ??
1085
1165
  getNextLayerId(state);
1086
1166
  const mediaId =
1087
1167
  command.asset.id ??
1088
- (shouldReplaceSingleLayerMedia ? getSingleMediaImportId(state) : undefined) ??
1168
+ existingSourceMediaAsset?.id ??
1169
+ (shouldReplaceSingleLayerMedia && !sourceTarget
1170
+ ? getSingleMediaImportId(state)
1171
+ : undefined) ??
1089
1172
  getNextMediaId(state);
1090
1173
  const layer = {
1091
1174
  displayName: command.asset.layerName ?? getImportedLayerName(command.asset.fileName),
@@ -1095,17 +1178,31 @@ export function toolcraftReducer(
1095
1178
  visible: true,
1096
1179
  };
1097
1180
  const mediaAsset = {
1181
+ ...(command.asset.assetKind ? { assetKind: command.asset.assetKind } : {}),
1098
1182
  dataUrl: command.asset.dataUrl,
1099
1183
  fileName: command.asset.fileName,
1100
1184
  id: mediaId,
1101
1185
  layerId,
1102
1186
  mimeType: command.asset.mimeType,
1103
1187
  position: shouldResizeCanvas ? { x: 0, y: 0 } : command.asset.position,
1104
- size: command.asset.size,
1188
+ ...(command.asset.size ? { size: command.asset.size } : {}),
1189
+ ...(sourceTarget ? { sourceTarget } : {}),
1105
1190
  };
1106
- 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];
1107
1198
  const mediaAssets = shouldReplaceSingleLayerMedia
1108
- ? [mediaAsset]
1199
+ ? sourceTarget
1200
+ ? existingSourceMediaAsset
1201
+ ? state.mediaAssets.map((asset) =>
1202
+ asset.id === existingSourceMediaAsset.id ? mediaAsset : asset,
1203
+ )
1204
+ : [...state.mediaAssets, mediaAsset]
1205
+ : [mediaAsset]
1109
1206
  : [...state.mediaAssets, mediaAsset];
1110
1207
  const after = {
1111
1208
  ...(shouldResizeCanvas ? { "canvas.size": command.asset.size } : {}),
@@ -1149,6 +1246,48 @@ export function toolcraftReducer(
1149
1246
  });
1150
1247
  }
1151
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
+
1152
1291
  case "timeline.setCurrentTime": {
1153
1292
  return {
1154
1293
  ...state,
@@ -1,4 +1,8 @@
1
- import type { ToolcraftCanvasSize, ResolvedToolcraftAppSchema } from "../schema/types";
1
+ import type {
2
+ ToolcraftCanvasSize,
3
+ ToolcraftFileDropAssetKind,
4
+ ResolvedToolcraftAppSchema,
5
+ } from "../schema/types";
2
6
 
3
7
  export type ToolcraftCommand =
4
8
  | {
@@ -44,6 +48,7 @@ export type ToolcraftCommand =
44
48
  type: "media.import";
45
49
  }
46
50
  | { mediaId: string; type: "media.delete" }
51
+ | { mediaIds: string[]; type: "media.reorder" }
47
52
  | { currentTimeSeconds: number; type: "timeline.setCurrentTime" }
48
53
  | { durationSeconds: number; type: "timeline.setDuration" }
49
54
  | { expanded: boolean; type: "timeline.setExpanded" }
@@ -104,6 +109,7 @@ export const toolcraftRuntimeCommandTypes = [
104
109
  "panels.resetOffset",
105
110
  "media.import",
106
111
  "media.delete",
112
+ "media.reorder",
107
113
  "timeline.setCurrentTime",
108
114
  "timeline.setDuration",
109
115
  "timeline.setExpanded",
@@ -156,13 +162,15 @@ export type ToolcraftLayerDraft = {
156
162
  };
157
163
 
158
164
  export type ToolcraftMediaAsset = {
165
+ assetKind?: ToolcraftFileDropAssetKind;
159
166
  dataUrl: string;
160
167
  fileName: string;
161
168
  id: string;
162
169
  layerId: string;
163
170
  mimeType: string;
164
171
  position: ToolcraftPoint;
165
- size: ToolcraftCanvasSize;
172
+ size?: ToolcraftCanvasSize;
173
+ sourceTarget?: string;
166
174
  };
167
175
 
168
176
  export type ToolcraftHistoryPatch = {