@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
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { DiamondIcon } from "@phosphor-icons/react";
4
+ import { ArrowCounterClockwiseIcon, DiamondIcon } from "@phosphor-icons/react";
5
5
  import {
6
6
  Actions,
7
7
  AnchorGrid,
@@ -9,6 +9,7 @@ import {
9
9
  ChannelMixer,
10
10
  Checkbox,
11
11
  CodeTextarea,
12
+ CollectionActions,
12
13
  Color,
13
14
  ColorOpacity,
14
15
  ControlInlineGroup,
@@ -69,7 +70,11 @@ import type {
69
70
  ToolcraftPanelState,
70
71
  ToolcraftState,
71
72
  } from "../state/types";
72
- import { readImportedImageFile } from "./media-file";
73
+ import {
74
+ isToolcraftImageFile,
75
+ readImportedFile,
76
+ readImportedImageFile,
77
+ } from "./media-file";
73
78
  import { PanelContainer } from "./panel-host";
74
79
  import type { PanelPlacement, PanelStateChange } from "./panel-host-types";
75
80
  import type { ToolcraftControlRendererMap } from "./control-renderers";
@@ -136,6 +141,7 @@ const canvasAspectRatioOptions = [
136
141
 
137
142
  const sectionedCompoundControlTypes = new Set([
138
143
  "channelMixer",
144
+ "collectionActions",
139
145
  "fontPicker",
140
146
  "gradient",
141
147
  "palette",
@@ -440,6 +446,8 @@ function formatControlValueLabel(
440
446
 
441
447
  return `${colorOpacityValue.hex} ${colorOpacityValue.opacity}%`;
442
448
  }
449
+ case "collectionActions":
450
+ return `${asCollectionItems(value, control.defaultValue).length} items`;
443
451
  case "fontPicker":
444
452
  return asFontPickerValue(value).fontId;
445
453
  case "gradient":
@@ -494,6 +502,10 @@ function formatControlValueLabel(
494
502
  }
495
503
 
496
504
  function asColorValue(value: unknown): { hex: string } {
505
+ if (typeof value === "string") {
506
+ return { hex: value };
507
+ }
508
+
497
509
  if (isRecord(value)) {
498
510
  return { hex: asString(value.hex, "#C1FF00") };
499
511
  }
@@ -502,6 +514,10 @@ function asColorValue(value: unknown): { hex: string } {
502
514
  }
503
515
 
504
516
  function asColorOpacityValue(value: unknown): ColorOpacityValue {
517
+ if (typeof value === "string") {
518
+ return { hex: value, opacity: 100 };
519
+ }
520
+
505
521
  if (isRecord(value)) {
506
522
  return {
507
523
  hex: asString(value.hex, "#C1FF00"),
@@ -512,6 +528,86 @@ function asColorOpacityValue(value: unknown): ColorOpacityValue {
512
528
  return { hex: "#C1FF00", opacity: 100 };
513
529
  }
514
530
 
531
+ function asCollectionItems(value: unknown, fallback: unknown): unknown[] {
532
+ if (Array.isArray(value)) {
533
+ return [...value];
534
+ }
535
+
536
+ return Array.isArray(fallback) ? [...fallback] : [];
537
+ }
538
+
539
+ function getCollectionMinItems(control: ToolcraftControlSchema): number {
540
+ return Math.max(0, Math.floor(asNumber(control.minItems, 0)));
541
+ }
542
+
543
+ function getCollectionHardMaxItems(control: ToolcraftControlSchema): number | null {
544
+ if (typeof control.hardMaxItems !== "number" || !Number.isFinite(control.hardMaxItems)) {
545
+ return null;
546
+ }
547
+
548
+ return Math.max(0, Math.floor(control.hardMaxItems));
549
+ }
550
+
551
+ function getCollectionItemType(control: ToolcraftControlSchema): string {
552
+ return control.itemControl?.type ?? "color";
553
+ }
554
+
555
+ function getCollectionItemBaseLabel(control: ToolcraftControlSchema): string {
556
+ if (control.itemLabel) {
557
+ return control.itemLabel;
558
+ }
559
+
560
+ const label = control.itemControl?.label;
561
+
562
+ return typeof label === "string" ? label : "Item";
563
+ }
564
+
565
+ function getCollectionItemName(
566
+ control: ToolcraftControlSchema,
567
+ index: number,
568
+ ): string {
569
+ const label = control.itemControl?.label;
570
+
571
+ if (label === false) {
572
+ return "";
573
+ }
574
+
575
+ return `${getCollectionItemBaseLabel(control)} ${index + 1}`;
576
+ }
577
+
578
+ function getCollectionItemDefaultValue(control: ToolcraftControlSchema): unknown {
579
+ if ("itemDefaultValue" in control) {
580
+ return control.itemDefaultValue;
581
+ }
582
+
583
+ if (typeof control.itemControl?.defaultValue !== "undefined") {
584
+ return control.itemControl.defaultValue;
585
+ }
586
+
587
+ switch (getCollectionItemType(control)) {
588
+ case "color":
589
+ return { hex: "#C1FF00" };
590
+ case "colorOpacity":
591
+ return { hex: "#C1FF00", opacity: 100 };
592
+ case "fontPicker":
593
+ return asFontPickerValue(control.itemControl?.defaultValue);
594
+ case "checkbox":
595
+ case "switch":
596
+ return false;
597
+ case "rangeInput":
598
+ return { end: "100%", start: "0%" };
599
+ case "select":
600
+ case "segmented":
601
+ return control.itemControl?.options?.[0]?.value ?? "";
602
+ case "slider":
603
+ return control.itemControl?.min ?? 0;
604
+ case "text":
605
+ return "";
606
+ default:
607
+ return "";
608
+ }
609
+ }
610
+
515
611
  function asGradientType(value: unknown): GradientType {
516
612
  return value === "linear" ||
517
613
  value === "radial" ||
@@ -1700,7 +1796,9 @@ export function ControlsPanel({
1700
1796
  }
1701
1797
 
1702
1798
  function isColorSectionTitle(sectionTitle: string | undefined): boolean {
1703
- return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1799
+ return /\b(colou?rs?|palette|palettes|shades?|accents?)\b/i.test(
1800
+ sectionTitle ?? "",
1801
+ );
1704
1802
  }
1705
1803
 
1706
1804
  function isSequentialColorLabel(label: string): boolean {
@@ -1844,6 +1942,42 @@ export function ControlsPanel({
1844
1942
  return getKeyframeLabelAction(control, name, getControlValue(control));
1845
1943
  }
1846
1944
 
1945
+ function getSectionResetAction({
1946
+ sectionTitle,
1947
+ targets,
1948
+ }: {
1949
+ sectionTitle: string;
1950
+ targets: readonly string[];
1951
+ }): React.ReactNode {
1952
+ const label = `Reset ${sectionTitle} section`;
1953
+
1954
+ return (
1955
+ <Tooltip>
1956
+ <TooltipTrigger
1957
+ render={
1958
+ <Button
1959
+ aria-label={label}
1960
+ data-control-section-reset-button=""
1961
+ onClick={() => {
1962
+ dispatchCommand({
1963
+ label,
1964
+ targets: Array.from(new Set(targets)),
1965
+ type: "controls.resetTargets",
1966
+ });
1967
+ }}
1968
+ size="icon-sm"
1969
+ type="button"
1970
+ variant="ghost"
1971
+ />
1972
+ }
1973
+ >
1974
+ <ArrowCounterClockwiseIcon />
1975
+ </TooltipTrigger>
1976
+ <TooltipContent side="top">{label}</TooltipContent>
1977
+ </Tooltip>
1978
+ );
1979
+ }
1980
+
1847
1981
  function renderColorGroup(
1848
1982
  entries: readonly ControlEntry[],
1849
1983
  headerKeyframeTarget: string | null,
@@ -1917,6 +2051,250 @@ export function ControlsPanel({
1917
2051
  );
1918
2052
  }
1919
2053
 
2054
+ function renderCollectionActionsControl({
2055
+ control,
2056
+ name,
2057
+ value,
2058
+ }: {
2059
+ control: ToolcraftControlSchema;
2060
+ name: string;
2061
+ value: unknown;
2062
+ }): React.JSX.Element {
2063
+ const items = asCollectionItems(value, control.defaultValue);
2064
+ const minItems = getCollectionMinItems(control);
2065
+ const hardMaxItems = getCollectionHardMaxItems(control);
2066
+ const canAdd = hardMaxItems === null || items.length < hardMaxItems;
2067
+ const canRemove = items.length > minItems;
2068
+ const itemType = getCollectionItemType(control);
2069
+
2070
+ function setItems(nextItems: unknown[], label: string): void {
2071
+ setControlValue(control.target, nextItems, label);
2072
+ }
2073
+
2074
+ function addItem(): void {
2075
+ if (!canAdd) {
2076
+ return;
2077
+ }
2078
+
2079
+ setItems([...items, getCollectionItemDefaultValue(control)], control.addLabel ?? "Add item");
2080
+ }
2081
+
2082
+ function removeItem(): void {
2083
+ if (!canRemove) {
2084
+ return;
2085
+ }
2086
+
2087
+ setItems(items.slice(0, -1), control.removeLabel ?? "Remove item");
2088
+ }
2089
+
2090
+ function updateItem(index: number, nextValue: unknown, meta?: ControlChangeMeta): void {
2091
+ const nextItems = items.map((item, itemIndex) =>
2092
+ itemIndex === index ? nextValue : item,
2093
+ );
2094
+ const itemName = getCollectionItemName(control, index) || name;
2095
+
2096
+ setControlValue(control.target, nextItems, itemName, meta);
2097
+ }
2098
+
2099
+ function renderColorItems(): React.ReactNode {
2100
+ return (
2101
+ <div
2102
+ className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-4"
2103
+ data-slot="collection-actions-items-grid"
2104
+ >
2105
+ {items.map((item, index) => {
2106
+ const itemName = getCollectionItemName(control, index);
2107
+
2108
+ return (
2109
+ <Color
2110
+ hex={asColorValue(item).hex}
2111
+ key={itemName || index}
2112
+ name={itemName}
2113
+ onValueChange={(nextValue: { hex: string }, meta?: ControlChangeMeta) =>
2114
+ updateItem(index, nextValue, meta)
2115
+ }
2116
+ showLabel={false}
2117
+ />
2118
+ );
2119
+ })}
2120
+ </div>
2121
+ );
2122
+ }
2123
+
2124
+ function renderColorOpacityItems(): React.ReactNode {
2125
+ return items.map((item, index) => {
2126
+ const colorOpacityValue = asColorOpacityValue(item);
2127
+ const itemName = getCollectionItemName(control, index);
2128
+
2129
+ return (
2130
+ <ColorOpacity
2131
+ hex={colorOpacityValue.hex}
2132
+ key={itemName || index}
2133
+ name={itemName}
2134
+ onValueChange={(nextValue, meta) => updateItem(index, nextValue, meta)}
2135
+ opacity={colorOpacityValue.opacity}
2136
+ showLabel={false}
2137
+ />
2138
+ );
2139
+ });
2140
+ }
2141
+
2142
+ function renderStackedItemControl(item: unknown, index: number): React.ReactNode {
2143
+ const itemControl = control.itemControl;
2144
+ const itemName = getCollectionItemName(control, index);
2145
+ const key = `${itemName || "item"}-${index}`;
2146
+ const update = (nextValue: unknown, meta?: ControlChangeMeta) => {
2147
+ updateItem(index, nextValue, meta);
2148
+ };
2149
+
2150
+ switch (itemType) {
2151
+ case "checkbox":
2152
+ return (
2153
+ <Checkbox
2154
+ checked={asBoolean(item)}
2155
+ key={key}
2156
+ name={itemName}
2157
+ onCheckedChange={update}
2158
+ showLabel={itemControl?.label !== false}
2159
+ />
2160
+ );
2161
+ case "rangeInput": {
2162
+ const rangeValue = asRangeInputValue(item);
2163
+
2164
+ return (
2165
+ <RangeInput
2166
+ defaultValue={asRangeInputValue(itemControl?.defaultValue)}
2167
+ end={rangeValue.end}
2168
+ key={key}
2169
+ name={itemName}
2170
+ onValueChange={update}
2171
+ start={rangeValue.start}
2172
+ />
2173
+ );
2174
+ }
2175
+ case "segmented":
2176
+ return (
2177
+ <Segmented
2178
+ key={key}
2179
+ name={itemName}
2180
+ onValueChange={update}
2181
+ options={itemControl?.options ?? []}
2182
+ value={asString(item, itemControl?.options?.[0]?.value ?? "")}
2183
+ variant={itemControl?.variant === "dots" ? "dots" : "default"}
2184
+ />
2185
+ );
2186
+ case "select":
2187
+ return (
2188
+ <Select
2189
+ key={key}
2190
+ name={itemName}
2191
+ onValueChange={update}
2192
+ options={itemControl?.options ?? []}
2193
+ value={asString(item, itemControl?.options?.[0]?.value ?? "")}
2194
+ />
2195
+ );
2196
+ case "slider":
2197
+ return (
2198
+ <Slider
2199
+ baseValue={asNumber(
2200
+ itemControl?.defaultValue,
2201
+ itemControl?.min ?? 0,
2202
+ )}
2203
+ key={key}
2204
+ markerCount={
2205
+ typeof itemControl?.markerCount === "number"
2206
+ ? itemControl.markerCount
2207
+ : undefined
2208
+ }
2209
+ max={itemControl?.max ?? 100}
2210
+ min={itemControl?.min ?? 0}
2211
+ name={itemName}
2212
+ onValueChange={update}
2213
+ step={itemControl?.step ?? 1}
2214
+ unit={itemControl?.unit}
2215
+ value={asNumber(
2216
+ item,
2217
+ asNumber(itemControl?.defaultValue, itemControl?.min ?? 0),
2218
+ )}
2219
+ variant={itemControl?.variant === "discrete" ? "discrete" : "continuous"}
2220
+ />
2221
+ );
2222
+ case "switch":
2223
+ return (
2224
+ <Switch
2225
+ checked={asBoolean(item)}
2226
+ key={key}
2227
+ name={itemName}
2228
+ onCheckedChange={update}
2229
+ showLabel={itemControl?.label !== false}
2230
+ />
2231
+ );
2232
+ case "fontPicker":
2233
+ return (
2234
+ <FontPicker
2235
+ defaultValue={asFontPickerValue(itemControl?.defaultValue)}
2236
+ key={key}
2237
+ name={itemName || "Font"}
2238
+ onValueChange={update}
2239
+ value={asFontPickerValue(item)}
2240
+ />
2241
+ );
2242
+ case "text":
2243
+ return (
2244
+ <TextInput
2245
+ commitOnBlur={itemControl?.commitMode === "setting"}
2246
+ defaultValue={asString(itemControl?.defaultValue, asString(item))}
2247
+ key={key}
2248
+ name={itemName}
2249
+ onValueChange={update}
2250
+ value={asString(item)}
2251
+ />
2252
+ );
2253
+ default:
2254
+ return (
2255
+ <TextInput
2256
+ defaultValue={asString(itemControl?.defaultValue, asString(item))}
2257
+ key={key}
2258
+ name={itemName}
2259
+ onValueChange={update}
2260
+ value={asString(item)}
2261
+ />
2262
+ );
2263
+ }
2264
+ }
2265
+
2266
+ function renderCollectionItems(): React.ReactNode {
2267
+ if (itemType === "color") {
2268
+ return renderColorItems();
2269
+ }
2270
+
2271
+ if (itemType === "colorOpacity") {
2272
+ return renderColorOpacityItems();
2273
+ }
2274
+
2275
+ return items.map(renderStackedItemControl);
2276
+ }
2277
+
2278
+ return (
2279
+ <div className="min-w-0 space-y-3" data-slot="collection-actions-control">
2280
+ <CollectionActions
2281
+ addLabel={control.addLabel ?? `Add ${getCollectionItemBaseLabel(control)}`}
2282
+ canAdd={canAdd}
2283
+ canRemove={canRemove}
2284
+ name={name}
2285
+ onAdd={addItem}
2286
+ onRemove={removeItem}
2287
+ removeLabel={
2288
+ control.removeLabel ?? `Remove ${getCollectionItemBaseLabel(control)}`
2289
+ }
2290
+ />
2291
+ <div className="min-w-0 space-y-4" data-slot="collection-actions-items">
2292
+ {renderCollectionItems()}
2293
+ </div>
2294
+ </div>
2295
+ );
2296
+ }
2297
+
1920
2298
  const visibleSections = resolvedControlsPanel.sections
1921
2299
  .map((section) => ({
1922
2300
  entries: getVisibleSectionEntries(section),
@@ -1963,6 +2341,23 @@ export function ControlsPanel({
1963
2341
  isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
1964
2342
  const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
1965
2343
  const headerKeyframeTarget = headerKeyframeEntry?.[1].target ?? null;
2344
+ const headerKeyframeAction = headerKeyframeEntry
2345
+ ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
2346
+ : null;
2347
+ const sectionResetAction = isSectionCollapsible
2348
+ ? getSectionResetAction({
2349
+ sectionTitle:
2350
+ typeof renderedSectionTitle === "string" ? renderedSectionTitle : "section",
2351
+ targets: entries.map(([, control]) => control.target),
2352
+ })
2353
+ : null;
2354
+ const sectionHeaderAction =
2355
+ headerKeyframeAction || sectionResetAction ? (
2356
+ <>
2357
+ {headerKeyframeAction}
2358
+ {sectionResetAction}
2359
+ </>
2360
+ ) : undefined;
1966
2361
  const inlineLayoutGroupByControlId = getInlineLayoutGroupByControlId({
1967
2362
  controlsById: visibleControls,
1968
2363
  layoutGroups: section.layoutGroups,
@@ -1970,11 +2365,7 @@ export function ControlsPanel({
1970
2365
 
1971
2366
  return (
1972
2367
  <PanelSection
1973
- action={
1974
- headerKeyframeEntry
1975
- ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
1976
- : undefined
1977
- }
2368
+ action={sectionHeaderAction}
1978
2369
  actionGroup={section.actionGroup}
1979
2370
  allowCompoundDividers={entries.length > 1}
1980
2371
  collapsed={isSectionCollapsed}
@@ -2190,6 +2581,9 @@ export function ControlsPanel({
2190
2581
  });
2191
2582
  }
2192
2583
 
2584
+ case "collectionActions":
2585
+ return renderCollectionActionsControl({ control, name, value });
2586
+
2193
2587
  case "curves": {
2194
2588
  const curvesName = control.label === false ? "Curves" : name;
2195
2589
 
@@ -2214,15 +2608,51 @@ export function ControlsPanel({
2214
2608
  }
2215
2609
 
2216
2610
  case "fileDrop": {
2217
- const previewMediaAssets = state.schema.panels.layers ? [] : state.mediaAssets;
2611
+ const assetKind = control.assetKind === "file" ? "file" : "image";
2612
+ const previewMediaAssets = state.schema.panels.layers
2613
+ ? []
2614
+ : state.mediaAssets.filter(
2615
+ (asset) =>
2616
+ asset.sourceTarget === undefined ||
2617
+ asset.sourceTarget === control.target,
2618
+ );
2218
2619
  const previewMediaAsset = previewMediaAssets[0];
2219
2620
  const previews = previewMediaAssets.map((asset): FileDropPreview => ({
2220
2621
  alt: asset.fileName,
2622
+ assetKind: asset.assetKind ?? "image",
2623
+ fileName: asset.fileName,
2221
2624
  id: asset.id,
2222
2625
  size: asset.size,
2223
2626
  src: asset.dataUrl,
2224
2627
  }));
2628
+ const previewMediaIds = previewMediaAssets.map((asset) => asset.id);
2225
2629
  const importFile = (file: File, replaceExisting: boolean): void => {
2630
+ if (assetKind === "file") {
2631
+ void readImportedFile(file).then((importedFile) => {
2632
+ if (!importedFile) {
2633
+ return;
2634
+ }
2635
+
2636
+ dispatchCommand({
2637
+ asset: {
2638
+ assetKind: "file",
2639
+ dataUrl: importedFile.dataUrl,
2640
+ fileName: file.name,
2641
+ mimeType: file.type || "application/octet-stream",
2642
+ position: { x: 0, y: 0 },
2643
+ sourceTarget: control.target,
2644
+ },
2645
+ replaceExisting,
2646
+ type: "media.import",
2647
+ });
2648
+ });
2649
+ return;
2650
+ }
2651
+
2652
+ if (!isToolcraftImageFile(file)) {
2653
+ return;
2654
+ }
2655
+
2226
2656
  void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2227
2657
  if (!importedImage) {
2228
2658
  return;
@@ -2230,11 +2660,13 @@ export function ControlsPanel({
2230
2660
 
2231
2661
  dispatchCommand({
2232
2662
  asset: {
2663
+ assetKind: "image",
2233
2664
  dataUrl: importedImage.dataUrl,
2234
2665
  fileName: file.name,
2235
2666
  mimeType: file.type || "image/*",
2236
2667
  position: { x: 0, y: 0 },
2237
2668
  size: importedImage.size,
2669
+ sourceTarget: control.target,
2238
2670
  },
2239
2671
  replaceExisting,
2240
2672
  type: "media.import",
@@ -2244,7 +2676,11 @@ export function ControlsPanel({
2244
2676
 
2245
2677
  return (
2246
2678
  <FileDrop
2247
- accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
2679
+ accept={
2680
+ control.accept ??
2681
+ (assetKind === "file" ? "" : "PNG, JPEG, GIF, SVG, WebP")
2682
+ }
2683
+ assetKind={assetKind}
2248
2684
  multiple={control.multiple}
2249
2685
  key={id}
2250
2686
  onClear={
@@ -2271,6 +2707,32 @@ export function ControlsPanel({
2271
2707
  type: "media.delete",
2272
2708
  });
2273
2709
  }}
2710
+ onPreviewReorder={(orderedPreviews) => {
2711
+ const orderedPreviewIds = orderedPreviews.flatMap((item) =>
2712
+ item.id ? [item.id] : [],
2713
+ );
2714
+
2715
+ if (orderedPreviewIds.length !== previewMediaIds.length) {
2716
+ return;
2717
+ }
2718
+
2719
+ const previewIdSet = new Set(previewMediaIds);
2720
+ let orderedPreviewIndex = 0;
2721
+ const mediaIds = state.mediaAssets.map((asset) => {
2722
+ if (!previewIdSet.has(asset.id)) {
2723
+ return asset.id;
2724
+ }
2725
+
2726
+ const nextId = orderedPreviewIds[orderedPreviewIndex];
2727
+ orderedPreviewIndex += 1;
2728
+ return nextId ?? asset.id;
2729
+ });
2730
+
2731
+ dispatchCommand({
2732
+ mediaIds,
2733
+ type: "media.reorder",
2734
+ });
2735
+ }}
2274
2736
  preview={
2275
2737
  previewMediaAsset
2276
2738
  ? {
@@ -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,
@@ -535,9 +535,11 @@ describe("defineToolcraft", () => {
535
535
  expect(app.assembly.commands).toEqual(
536
536
  expect.arrayContaining([
537
537
  "controls.reset",
538
+ "controls.resetTargets",
538
539
  "history.undo",
539
540
  "media.delete",
540
541
  "media.import",
542
+ "media.reorder",
541
543
  "layers.reorder",
542
544
  "timeline.setCurrentTime",
543
545
  "canvas.center",
@@ -60,6 +60,7 @@ const runtimeSetupSectionTitle = "Setup";
60
60
  const settingsTransferHeavyControlTypes = new Set([
61
61
  "channelMixer",
62
62
  "code",
63
+ "collectionActions",
63
64
  "colorOpacity",
64
65
  "curves",
65
66
  "fileDrop",
@@ -331,14 +332,19 @@ function createToolcraftAssembly({
331
332
 
332
333
  if (canvas.upload) {
333
334
  capabilities.push("canvas.upload");
334
- commands.push("media.delete", "media.import");
335
+ commands.push("media.delete", "media.import", "media.reorder");
335
336
  }
336
337
  }
337
338
 
338
339
  const controlsPanel = panels.controls
339
340
  ? createPanelAssemblyContract({
340
341
  capabilities: ["controls.panel", "controls.defaults"],
341
- commands: ["controls.apply", "controls.reset", "controls.setValue"],
342
+ commands: [
343
+ "controls.apply",
344
+ "controls.reset",
345
+ "controls.resetTargets",
346
+ "controls.setValue",
347
+ ],
342
348
  contract: TOOLCRAFT_COMPONENT_CONTRACTS.controlsPanel,
343
349
  enabled: true,
344
350
  })
@@ -472,7 +478,9 @@ function createToolcraftAssembly({
472
478
  ...(canvas.draggable
473
479
  ? (["canvas.panBy", "canvas.setOffset", "canvas.setViewport"] as const)
474
480
  : []),
475
- ...(canvas.upload ? (["media.delete", "media.import"] as const) : []),
481
+ ...(canvas.upload
482
+ ? (["media.delete", "media.import", "media.reorder"] as const)
483
+ : []),
476
484
  ])
477
485
  : [],
478
486
  enabled: canvas.enabled,