@pixel-point/toolcraft 0.0.4 → 0.0.7

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 (49) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +7 -1
  5. package/templates/runtime/contracts/component-contracts.test.ts +96 -3
  6. package/templates/runtime/contracts/component-contracts.ts +75 -3
  7. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  8. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  9. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  10. package/templates/runtime/react/controls-panel.test.tsx +455 -1
  11. package/templates/runtime/react/controls-panel.tsx +515 -30
  12. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  13. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  14. package/templates/runtime/react/timeline-panel.tsx +98 -10
  15. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  16. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  17. package/templates/runtime/schema/define-toolcraft.test.ts +78 -1
  18. package/templates/runtime/schema/define-toolcraft.ts +145 -6
  19. package/templates/runtime/schema/runtime-targets.ts +1 -0
  20. package/templates/runtime/schema/types.ts +46 -1
  21. package/templates/runtime/state/canvas-zoom.ts +1 -1
  22. package/templates/runtime/state/create-template-state.test.ts +6 -6
  23. package/templates/runtime/state/reducer.test.ts +139 -8
  24. package/templates/runtime/state/reducer.ts +88 -22
  25. package/templates/runtime/state/types.ts +3 -0
  26. package/templates/starter/AGENTS.md +8 -8
  27. package/templates/starter/docs/toolcraft/README.md +4 -3
  28. package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
  29. package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
  30. package/templates/starter/docs/toolcraft/component-rules.md +17 -1
  31. package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
  32. package/templates/starter/docs/toolcraft/performance.md +8 -7
  33. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  34. package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
  35. package/templates/starter/docs/toolcraft/workflow.md +5 -8
  36. package/templates/starter/gitignore +36 -0
  37. package/templates/starter/package.json +1 -1
  38. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  39. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  40. package/templates/starter/src/app/starter-performance.test.ts +1 -1
  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 +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  45. package/templates/ui/components/controls/index.ts +8 -0
  46. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  47. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  48. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
  49. 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,
@@ -42,6 +43,7 @@ import {
42
43
  type ColorOpacityValue,
43
44
  type CurveInterpolation,
44
45
  type FontPickerValue,
46
+ type FileDropPreview,
45
47
  type GradientStop,
46
48
  type GradientType,
47
49
  type ImagePickerItem,
@@ -135,6 +137,7 @@ const canvasAspectRatioOptions = [
135
137
 
136
138
  const sectionedCompoundControlTypes = new Set([
137
139
  "channelMixer",
140
+ "collectionActions",
138
141
  "fontPicker",
139
142
  "gradient",
140
143
  "palette",
@@ -439,6 +442,8 @@ function formatControlValueLabel(
439
442
 
440
443
  return `${colorOpacityValue.hex} ${colorOpacityValue.opacity}%`;
441
444
  }
445
+ case "collectionActions":
446
+ return `${asCollectionItems(value, control.defaultValue).length} items`;
442
447
  case "fontPicker":
443
448
  return asFontPickerValue(value).fontId;
444
449
  case "gradient":
@@ -493,6 +498,10 @@ function formatControlValueLabel(
493
498
  }
494
499
 
495
500
  function asColorValue(value: unknown): { hex: string } {
501
+ if (typeof value === "string") {
502
+ return { hex: value };
503
+ }
504
+
496
505
  if (isRecord(value)) {
497
506
  return { hex: asString(value.hex, "#C1FF00") };
498
507
  }
@@ -501,6 +510,10 @@ function asColorValue(value: unknown): { hex: string } {
501
510
  }
502
511
 
503
512
  function asColorOpacityValue(value: unknown): ColorOpacityValue {
513
+ if (typeof value === "string") {
514
+ return { hex: value, opacity: 100 };
515
+ }
516
+
504
517
  if (isRecord(value)) {
505
518
  return {
506
519
  hex: asString(value.hex, "#C1FF00"),
@@ -511,6 +524,84 @@ function asColorOpacityValue(value: unknown): ColorOpacityValue {
511
524
  return { hex: "#C1FF00", opacity: 100 };
512
525
  }
513
526
 
527
+ function asCollectionItems(value: unknown, fallback: unknown): unknown[] {
528
+ if (Array.isArray(value)) {
529
+ return [...value];
530
+ }
531
+
532
+ return Array.isArray(fallback) ? [...fallback] : [];
533
+ }
534
+
535
+ function getCollectionMinItems(control: ToolcraftControlSchema): number {
536
+ return Math.max(0, Math.floor(asNumber(control.minItems, 0)));
537
+ }
538
+
539
+ function getCollectionHardMaxItems(control: ToolcraftControlSchema): number | null {
540
+ if (typeof control.hardMaxItems !== "number" || !Number.isFinite(control.hardMaxItems)) {
541
+ return null;
542
+ }
543
+
544
+ return Math.max(0, Math.floor(control.hardMaxItems));
545
+ }
546
+
547
+ function getCollectionItemType(control: ToolcraftControlSchema): string {
548
+ return control.itemControl?.type ?? "color";
549
+ }
550
+
551
+ function getCollectionItemBaseLabel(control: ToolcraftControlSchema): string {
552
+ if (control.itemLabel) {
553
+ return control.itemLabel;
554
+ }
555
+
556
+ const label = control.itemControl?.label;
557
+
558
+ return typeof label === "string" ? label : "Item";
559
+ }
560
+
561
+ function getCollectionItemName(
562
+ control: ToolcraftControlSchema,
563
+ index: number,
564
+ ): string {
565
+ const label = control.itemControl?.label;
566
+
567
+ if (label === false) {
568
+ return "";
569
+ }
570
+
571
+ return `${getCollectionItemBaseLabel(control)} ${index + 1}`;
572
+ }
573
+
574
+ function getCollectionItemDefaultValue(control: ToolcraftControlSchema): unknown {
575
+ if ("itemDefaultValue" in control) {
576
+ return control.itemDefaultValue;
577
+ }
578
+
579
+ if (typeof control.itemControl?.defaultValue !== "undefined") {
580
+ return control.itemControl.defaultValue;
581
+ }
582
+
583
+ switch (getCollectionItemType(control)) {
584
+ case "color":
585
+ return { hex: "#C1FF00" };
586
+ case "colorOpacity":
587
+ return { hex: "#C1FF00", opacity: 100 };
588
+ case "checkbox":
589
+ case "switch":
590
+ return false;
591
+ case "rangeInput":
592
+ return { end: "100%", start: "0%" };
593
+ case "select":
594
+ case "segmented":
595
+ return control.itemControl?.options?.[0]?.value ?? "";
596
+ case "slider":
597
+ return control.itemControl?.min ?? 0;
598
+ case "text":
599
+ return "";
600
+ default:
601
+ return "";
602
+ }
603
+ }
604
+
514
605
  function asGradientType(value: unknown): GradientType {
515
606
  return value === "linear" ||
516
607
  value === "radial" ||
@@ -1691,12 +1782,94 @@ export function ControlsPanel({
1691
1782
  );
1692
1783
  }
1693
1784
 
1694
- function getControlHelpText(
1695
- control: ToolcraftControlSchema,
1696
- ): string | null {
1785
+ function normalizeControlHelpContext(value: string): string {
1786
+ return value
1787
+ .toLowerCase()
1788
+ .replace(/[^a-z0-9]+/g, " ")
1789
+ .trim();
1790
+ }
1791
+
1792
+ function isColorSectionTitle(sectionTitle: string | undefined): boolean {
1793
+ return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1794
+ }
1795
+
1796
+ function isSequentialColorLabel(label: string): boolean {
1797
+ return /^colou?r\s+\d+$/i.test(label.trim());
1798
+ }
1799
+
1800
+ function isSimplePaletteDistributionLabel(label: string): boolean {
1801
+ return /^(spread|mix|distribution)$/i.test(label.trim());
1802
+ }
1803
+
1804
+ function isGenericControlHelpDescription(description: string): boolean {
1805
+ return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
1806
+ description.trim(),
1807
+ );
1808
+ }
1809
+
1810
+ function shouldSuppressObviousControlHelp({
1811
+ control,
1812
+ description,
1813
+ label,
1814
+ sectionTitle,
1815
+ }: {
1816
+ control: ToolcraftControlSchema;
1817
+ description: string;
1818
+ label: string;
1819
+ sectionTitle: string | undefined;
1820
+ }): boolean {
1821
+ const isColorControl = control.type === "color" || control.type === "colorOpacity";
1822
+
1823
+ if (isColorSectionTitle(sectionTitle)) {
1824
+ if (isColorControl && isSequentialColorLabel(label)) {
1825
+ return true;
1826
+ }
1827
+
1828
+ if (
1829
+ isSimplePaletteDistributionLabel(label) &&
1830
+ isGenericControlHelpDescription(description)
1831
+ ) {
1832
+ return true;
1833
+ }
1834
+ }
1835
+
1836
+ const normalizedDescription = normalizeControlHelpContext(description);
1837
+ const normalizedLabel = normalizeControlHelpContext(label);
1838
+
1839
+ return (
1840
+ Boolean(normalizedLabel) &&
1841
+ isGenericControlHelpDescription(description) &&
1842
+ normalizedDescription === normalizedLabel
1843
+ );
1844
+ }
1845
+
1846
+ function getControlHelpText({
1847
+ control,
1848
+ label,
1849
+ sectionTitle,
1850
+ }: {
1851
+ control: ToolcraftControlSchema;
1852
+ label: string;
1853
+ sectionTitle: string | undefined;
1854
+ }): string | null {
1697
1855
  const description = control.description?.trim();
1698
1856
 
1699
- return description || null;
1857
+ if (!description) {
1858
+ return null;
1859
+ }
1860
+
1861
+ if (
1862
+ shouldSuppressObviousControlHelp({
1863
+ control,
1864
+ description,
1865
+ label,
1866
+ sectionTitle,
1867
+ })
1868
+ ) {
1869
+ return null;
1870
+ }
1871
+
1872
+ return description;
1700
1873
  }
1701
1874
 
1702
1875
  function withControlLabelHelp({
@@ -1704,13 +1877,15 @@ export function ControlsPanel({
1704
1877
  control,
1705
1878
  label,
1706
1879
  providerKey,
1880
+ sectionTitle,
1707
1881
  }: {
1708
1882
  children: React.ReactNode;
1709
1883
  control: ToolcraftControlSchema;
1710
1884
  label: string;
1711
1885
  providerKey: string;
1886
+ sectionTitle: string | undefined;
1712
1887
  }): React.ReactNode {
1713
- const help = getControlHelpText(control);
1888
+ const help = getControlHelpText({ control, label, sectionTitle });
1714
1889
 
1715
1890
  if (!help) {
1716
1891
  return children;
@@ -1759,6 +1934,42 @@ export function ControlsPanel({
1759
1934
  return getKeyframeLabelAction(control, name, getControlValue(control));
1760
1935
  }
1761
1936
 
1937
+ function getSectionResetAction({
1938
+ sectionTitle,
1939
+ targets,
1940
+ }: {
1941
+ sectionTitle: string;
1942
+ targets: readonly string[];
1943
+ }): React.ReactNode {
1944
+ const label = `Reset ${sectionTitle} section`;
1945
+
1946
+ return (
1947
+ <Tooltip>
1948
+ <TooltipTrigger
1949
+ render={
1950
+ <Button
1951
+ aria-label={label}
1952
+ data-control-section-reset-button=""
1953
+ onClick={() => {
1954
+ dispatchCommand({
1955
+ label,
1956
+ targets: Array.from(new Set(targets)),
1957
+ type: "controls.resetTargets",
1958
+ });
1959
+ }}
1960
+ size="icon-sm"
1961
+ type="button"
1962
+ variant="ghost"
1963
+ />
1964
+ }
1965
+ >
1966
+ <ArrowCounterClockwiseIcon />
1967
+ </TooltipTrigger>
1968
+ <TooltipContent side="top">{label}</TooltipContent>
1969
+ </Tooltip>
1970
+ );
1971
+ }
1972
+
1762
1973
  function renderColorGroup(
1763
1974
  entries: readonly ControlEntry[],
1764
1975
  headerKeyframeTarget: string | null,
@@ -1832,6 +2043,240 @@ export function ControlsPanel({
1832
2043
  );
1833
2044
  }
1834
2045
 
2046
+ function renderCollectionActionsControl({
2047
+ control,
2048
+ name,
2049
+ value,
2050
+ }: {
2051
+ control: ToolcraftControlSchema;
2052
+ name: string;
2053
+ value: unknown;
2054
+ }): React.JSX.Element {
2055
+ const items = asCollectionItems(value, control.defaultValue);
2056
+ const minItems = getCollectionMinItems(control);
2057
+ const hardMaxItems = getCollectionHardMaxItems(control);
2058
+ const canAdd = hardMaxItems === null || items.length < hardMaxItems;
2059
+ const canRemove = items.length > minItems;
2060
+ const itemType = getCollectionItemType(control);
2061
+
2062
+ function setItems(nextItems: unknown[], label: string): void {
2063
+ setControlValue(control.target, nextItems, label);
2064
+ }
2065
+
2066
+ function addItem(): void {
2067
+ if (!canAdd) {
2068
+ return;
2069
+ }
2070
+
2071
+ setItems([...items, getCollectionItemDefaultValue(control)], control.addLabel ?? "Add item");
2072
+ }
2073
+
2074
+ function removeItem(): void {
2075
+ if (!canRemove) {
2076
+ return;
2077
+ }
2078
+
2079
+ setItems(items.slice(0, -1), control.removeLabel ?? "Remove item");
2080
+ }
2081
+
2082
+ function updateItem(index: number, nextValue: unknown, meta?: ControlChangeMeta): void {
2083
+ const nextItems = items.map((item, itemIndex) =>
2084
+ itemIndex === index ? nextValue : item,
2085
+ );
2086
+ const itemName = getCollectionItemName(control, index) || name;
2087
+
2088
+ setControlValue(control.target, nextItems, itemName, meta);
2089
+ }
2090
+
2091
+ function renderColorItems(): React.ReactNode {
2092
+ return (
2093
+ <div
2094
+ className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-4"
2095
+ data-slot="collection-actions-items-grid"
2096
+ >
2097
+ {items.map((item, index) => {
2098
+ const itemName = getCollectionItemName(control, index);
2099
+
2100
+ return (
2101
+ <Color
2102
+ hex={asColorValue(item).hex}
2103
+ key={itemName || index}
2104
+ name={itemName}
2105
+ onValueChange={(nextValue: { hex: string }, meta?: ControlChangeMeta) =>
2106
+ updateItem(index, nextValue, meta)
2107
+ }
2108
+ showLabel={false}
2109
+ />
2110
+ );
2111
+ })}
2112
+ </div>
2113
+ );
2114
+ }
2115
+
2116
+ function renderColorOpacityItems(): React.ReactNode {
2117
+ return items.map((item, index) => {
2118
+ const colorOpacityValue = asColorOpacityValue(item);
2119
+ const itemName = getCollectionItemName(control, index);
2120
+
2121
+ return (
2122
+ <ColorOpacity
2123
+ hex={colorOpacityValue.hex}
2124
+ key={itemName || index}
2125
+ name={itemName}
2126
+ onValueChange={(nextValue, meta) => updateItem(index, nextValue, meta)}
2127
+ opacity={colorOpacityValue.opacity}
2128
+ showLabel={false}
2129
+ />
2130
+ );
2131
+ });
2132
+ }
2133
+
2134
+ function renderStackedItemControl(item: unknown, index: number): React.ReactNode {
2135
+ const itemControl = control.itemControl;
2136
+ const itemName = getCollectionItemName(control, index);
2137
+ const key = `${itemName || "item"}-${index}`;
2138
+ const update = (nextValue: unknown, meta?: ControlChangeMeta) => {
2139
+ updateItem(index, nextValue, meta);
2140
+ };
2141
+
2142
+ switch (itemType) {
2143
+ case "checkbox":
2144
+ return (
2145
+ <Checkbox
2146
+ checked={asBoolean(item)}
2147
+ key={key}
2148
+ name={itemName}
2149
+ onCheckedChange={update}
2150
+ showLabel={itemControl?.label !== false}
2151
+ />
2152
+ );
2153
+ case "rangeInput": {
2154
+ const rangeValue = asRangeInputValue(item);
2155
+
2156
+ return (
2157
+ <RangeInput
2158
+ defaultValue={asRangeInputValue(itemControl?.defaultValue)}
2159
+ end={rangeValue.end}
2160
+ key={key}
2161
+ name={itemName}
2162
+ onValueChange={update}
2163
+ start={rangeValue.start}
2164
+ />
2165
+ );
2166
+ }
2167
+ case "segmented":
2168
+ return (
2169
+ <Segmented
2170
+ key={key}
2171
+ name={itemName}
2172
+ onValueChange={update}
2173
+ options={itemControl?.options ?? []}
2174
+ value={asString(item, itemControl?.options?.[0]?.value ?? "")}
2175
+ variant={itemControl?.variant === "dots" ? "dots" : "default"}
2176
+ />
2177
+ );
2178
+ case "select":
2179
+ return (
2180
+ <Select
2181
+ key={key}
2182
+ name={itemName}
2183
+ onValueChange={update}
2184
+ options={itemControl?.options ?? []}
2185
+ value={asString(item, itemControl?.options?.[0]?.value ?? "")}
2186
+ />
2187
+ );
2188
+ case "slider":
2189
+ return (
2190
+ <Slider
2191
+ baseValue={asNumber(
2192
+ itemControl?.defaultValue,
2193
+ itemControl?.min ?? 0,
2194
+ )}
2195
+ key={key}
2196
+ markerCount={
2197
+ typeof itemControl?.markerCount === "number"
2198
+ ? itemControl.markerCount
2199
+ : undefined
2200
+ }
2201
+ max={itemControl?.max ?? 100}
2202
+ min={itemControl?.min ?? 0}
2203
+ name={itemName}
2204
+ onValueChange={update}
2205
+ step={itemControl?.step ?? 1}
2206
+ unit={itemControl?.unit}
2207
+ value={asNumber(
2208
+ item,
2209
+ asNumber(itemControl?.defaultValue, itemControl?.min ?? 0),
2210
+ )}
2211
+ variant={itemControl?.variant === "discrete" ? "discrete" : "continuous"}
2212
+ />
2213
+ );
2214
+ case "switch":
2215
+ return (
2216
+ <Switch
2217
+ checked={asBoolean(item)}
2218
+ key={key}
2219
+ name={itemName}
2220
+ onCheckedChange={update}
2221
+ showLabel={itemControl?.label !== false}
2222
+ />
2223
+ );
2224
+ case "text":
2225
+ return (
2226
+ <TextInput
2227
+ commitOnBlur={itemControl?.commitMode === "setting"}
2228
+ defaultValue={asString(itemControl?.defaultValue, asString(item))}
2229
+ key={key}
2230
+ name={itemName}
2231
+ onValueChange={update}
2232
+ value={asString(item)}
2233
+ />
2234
+ );
2235
+ default:
2236
+ return (
2237
+ <TextInput
2238
+ defaultValue={asString(itemControl?.defaultValue, asString(item))}
2239
+ key={key}
2240
+ name={itemName}
2241
+ onValueChange={update}
2242
+ value={asString(item)}
2243
+ />
2244
+ );
2245
+ }
2246
+ }
2247
+
2248
+ function renderCollectionItems(): React.ReactNode {
2249
+ if (itemType === "color") {
2250
+ return renderColorItems();
2251
+ }
2252
+
2253
+ if (itemType === "colorOpacity") {
2254
+ return renderColorOpacityItems();
2255
+ }
2256
+
2257
+ return items.map(renderStackedItemControl);
2258
+ }
2259
+
2260
+ return (
2261
+ <div className="min-w-0 space-y-3" data-slot="collection-actions-control">
2262
+ <CollectionActions
2263
+ addLabel={control.addLabel ?? `Add ${getCollectionItemBaseLabel(control)}`}
2264
+ canAdd={canAdd}
2265
+ canRemove={canRemove}
2266
+ name={name}
2267
+ onAdd={addItem}
2268
+ onRemove={removeItem}
2269
+ removeLabel={
2270
+ control.removeLabel ?? `Remove ${getCollectionItemBaseLabel(control)}`
2271
+ }
2272
+ />
2273
+ <div className="min-w-0 space-y-4" data-slot="collection-actions-items">
2274
+ {renderCollectionItems()}
2275
+ </div>
2276
+ </div>
2277
+ );
2278
+ }
2279
+
1835
2280
  const visibleSections = resolvedControlsPanel.sections
1836
2281
  .map((section) => ({
1837
2282
  entries: getVisibleSectionEntries(section),
@@ -1878,6 +2323,23 @@ export function ControlsPanel({
1878
2323
  isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
1879
2324
  const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
1880
2325
  const headerKeyframeTarget = headerKeyframeEntry?.[1].target ?? null;
2326
+ const headerKeyframeAction = headerKeyframeEntry
2327
+ ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
2328
+ : null;
2329
+ const sectionResetAction = isSectionCollapsible
2330
+ ? getSectionResetAction({
2331
+ sectionTitle:
2332
+ typeof renderedSectionTitle === "string" ? renderedSectionTitle : "section",
2333
+ targets: entries.map(([, control]) => control.target),
2334
+ })
2335
+ : null;
2336
+ const sectionHeaderAction =
2337
+ headerKeyframeAction || sectionResetAction ? (
2338
+ <>
2339
+ {headerKeyframeAction}
2340
+ {sectionResetAction}
2341
+ </>
2342
+ ) : undefined;
1881
2343
  const inlineLayoutGroupByControlId = getInlineLayoutGroupByControlId({
1882
2344
  controlsById: visibleControls,
1883
2345
  layoutGroups: section.layoutGroups,
@@ -1885,11 +2347,7 @@ export function ControlsPanel({
1885
2347
 
1886
2348
  return (
1887
2349
  <PanelSection
1888
- action={
1889
- headerKeyframeEntry
1890
- ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
1891
- : undefined
1892
- }
2350
+ action={sectionHeaderAction}
1893
2351
  actionGroup={section.actionGroup}
1894
2352
  allowCompoundDividers={entries.length > 1}
1895
2353
  collapsed={isSectionCollapsed}
@@ -2105,6 +2563,9 @@ export function ControlsPanel({
2105
2563
  });
2106
2564
  }
2107
2565
 
2566
+ case "collectionActions":
2567
+ return renderCollectionActionsControl({ control, name, value });
2568
+
2108
2569
  case "curves": {
2109
2570
  const curvesName = control.label === false ? "Curves" : name;
2110
2571
 
@@ -2129,13 +2590,38 @@ export function ControlsPanel({
2129
2590
  }
2130
2591
 
2131
2592
  case "fileDrop": {
2132
- const previewMediaAsset = state.schema.panels.layers
2133
- ? undefined
2134
- : state.mediaAssets[0];
2593
+ const previewMediaAssets = state.schema.panels.layers ? [] : state.mediaAssets;
2594
+ const previewMediaAsset = previewMediaAssets[0];
2595
+ const previews = previewMediaAssets.map((asset): FileDropPreview => ({
2596
+ alt: asset.fileName,
2597
+ id: asset.id,
2598
+ size: asset.size,
2599
+ src: asset.dataUrl,
2600
+ }));
2601
+ const importFile = (file: File, replaceExisting: boolean): void => {
2602
+ void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2603
+ if (!importedImage) {
2604
+ return;
2605
+ }
2606
+
2607
+ dispatchCommand({
2608
+ asset: {
2609
+ dataUrl: importedImage.dataUrl,
2610
+ fileName: file.name,
2611
+ mimeType: file.type || "image/*",
2612
+ position: { x: 0, y: 0 },
2613
+ size: importedImage.size,
2614
+ },
2615
+ replaceExisting,
2616
+ type: "media.import",
2617
+ });
2618
+ });
2619
+ };
2135
2620
 
2136
2621
  return (
2137
2622
  <FileDrop
2138
2623
  accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
2624
+ multiple={control.multiple}
2139
2625
  key={id}
2140
2626
  onClear={
2141
2627
  previewMediaAsset
@@ -2147,33 +2633,31 @@ export function ControlsPanel({
2147
2633
  }
2148
2634
  : undefined
2149
2635
  }
2150
- onFileSelect={(file) => {
2151
- void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2152
- if (!importedImage) {
2153
- return;
2154
- }
2155
-
2156
- dispatchCommand({
2157
- asset: {
2158
- dataUrl: importedImage.dataUrl,
2159
- fileName: file.name,
2160
- mimeType: file.type || "image/*",
2161
- position: { x: 0, y: 0 },
2162
- size: importedImage.size,
2163
- },
2164
- type: "media.import",
2165
- });
2636
+ onFilesSelect={(files) => {
2637
+ files.forEach((file) => importFile(file, false));
2638
+ }}
2639
+ onFileSelect={(file) => importFile(file, true)}
2640
+ onPreviewRemove={(item) => {
2641
+ if (!item.id) {
2642
+ return;
2643
+ }
2644
+
2645
+ dispatchCommand({
2646
+ mediaId: item.id,
2647
+ type: "media.delete",
2166
2648
  });
2167
2649
  }}
2168
2650
  preview={
2169
2651
  previewMediaAsset
2170
2652
  ? {
2653
+ id: previewMediaAsset.id,
2171
2654
  alt: previewMediaAsset.fileName,
2172
2655
  size: previewMediaAsset.size,
2173
2656
  src: previewMediaAsset.dataUrl,
2174
2657
  }
2175
2658
  : undefined
2176
2659
  }
2660
+ previews={previews}
2177
2661
  />
2178
2662
  );
2179
2663
  }
@@ -2514,6 +2998,7 @@ export function ControlsPanel({
2514
2998
  control,
2515
2999
  label: name,
2516
3000
  providerKey: id,
3001
+ sectionTitle: section.title,
2517
3002
  }),
2518
3003
  control,
2519
3004
  }),
@@ -131,10 +131,10 @@ describe("settings transfer", () => {
131
131
  expect(state.values["generation.prompt"]).toBe("Imported prompt");
132
132
  expect(state.values["unknown.target"]).toBeUndefined();
133
133
  expect(state.values["canvas.aspectRatio"]).toEqual({
134
- height: 900,
134
+ height: 9,
135
135
  mode: "custom",
136
- value: "1600:900",
137
- width: 1600,
136
+ value: "16:9",
137
+ width: 16,
138
138
  });
139
139
  expect(state.canvas.size).toEqual({ height: 900, unit: "px", width: 1600 });
140
140
  expect(state.timeline).toMatchObject({