@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.
- package/package.json +1 -1
- package/src/generate.test.mjs +1 -1
- package/templates/runtime/contracts/component-contracts.test.ts +123 -15
- package/templates/runtime/contracts/component-contracts.ts +93 -19
- package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
- package/templates/runtime/contracts/decision-contracts.ts +2 -2
- package/templates/runtime/export/export.test.ts +31 -0
- package/templates/runtime/export/export.ts +41 -0
- package/templates/runtime/react/canvas-shell.test.tsx +77 -1
- package/templates/runtime/react/canvas-shell.tsx +178 -23
- package/templates/runtime/react/control-conditions.ts +166 -0
- package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
- package/templates/runtime/react/controls-panel.test.tsx +590 -9
- package/templates/runtime/react/controls-panel.tsx +472 -10
- package/templates/runtime/react/media-file.ts +19 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
- package/templates/runtime/schema/define-toolcraft.ts +11 -3
- package/templates/runtime/schema/types.ts +27 -0
- package/templates/runtime/state/reducer.test.ts +357 -0
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/types.ts +12 -2
- package/templates/runtime/testing/performance.test.ts +1282 -48
- package/templates/runtime/testing/performance.ts +676 -39
- package/templates/starter/AGENTS.md +13 -12
- package/templates/starter/docs/toolcraft/README.md +4 -3
- package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
- package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
- package/templates/starter/docs/toolcraft/component-rules.md +20 -7
- package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
- package/templates/starter/docs/toolcraft/performance.md +51 -13
- package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
- package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
- package/templates/starter/docs/toolcraft/workflow.md +7 -10
- package/templates/starter/e2e/app-performance.spec.ts +136 -3
- package/templates/starter/e2e/performance-helpers.ts +197 -0
- package/templates/starter/package.json +4 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
- package/templates/starter/src/app/starter-acceptance.ts +269 -12
- package/templates/starter/src/app/starter-performance.test.ts +67 -6
- package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
- package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
- package/templates/ui/components/controls/collection-actions/index.ts +4 -0
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
- package/templates/ui/components/controls/file-drop/index.ts +1 -1
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
- package/templates/ui/components/controls/index.ts +9 -0
- package/templates/ui/components/panel/panel-section.tsx +53 -1
- 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 {
|
|
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(
|
|
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
|
|
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={
|
|
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: [
|
|
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
|
|
481
|
+
...(canvas.upload
|
|
482
|
+
? (["media.delete", "media.import", "media.reorder"] as const)
|
|
483
|
+
: []),
|
|
476
484
|
])
|
|
477
485
|
: [],
|
|
478
486
|
enabled: canvas.enabled,
|