@pixel-point/toolcraft 0.0.6 → 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 (30) 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 +39 -3
  4. package/templates/runtime/contracts/component-contracts.ts +54 -3
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  7. package/templates/runtime/react/controls-panel.test.tsx +186 -1
  8. package/templates/runtime/react/controls-panel.tsx +382 -6
  9. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  10. package/templates/runtime/schema/define-toolcraft.ts +7 -1
  11. package/templates/runtime/schema/types.ts +23 -0
  12. package/templates/runtime/state/reducer.test.ts +53 -0
  13. package/templates/runtime/state/reducer.ts +47 -0
  14. package/templates/runtime/state/types.ts +2 -0
  15. package/templates/starter/AGENTS.md +7 -7
  16. package/templates/starter/docs/toolcraft/README.md +4 -3
  17. package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
  18. package/templates/starter/docs/toolcraft/assembly-workflow.md +5 -5
  19. package/templates/starter/docs/toolcraft/component-rules.md +5 -1
  20. package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
  21. package/templates/starter/docs/toolcraft/performance.md +4 -8
  22. package/templates/starter/docs/toolcraft/schema-reference.md +6 -1
  23. package/templates/starter/docs/toolcraft/workflow.md +5 -8
  24. package/templates/starter/package.json +1 -1
  25. package/templates/starter/src/app/starter-performance.test.ts +1 -1
  26. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  27. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  28. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  29. package/templates/ui/components/controls/index.ts +8 -0
  30. 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,
@@ -136,6 +137,7 @@ const canvasAspectRatioOptions = [
136
137
 
137
138
  const sectionedCompoundControlTypes = new Set([
138
139
  "channelMixer",
140
+ "collectionActions",
139
141
  "fontPicker",
140
142
  "gradient",
141
143
  "palette",
@@ -440,6 +442,8 @@ function formatControlValueLabel(
440
442
 
441
443
  return `${colorOpacityValue.hex} ${colorOpacityValue.opacity}%`;
442
444
  }
445
+ case "collectionActions":
446
+ return `${asCollectionItems(value, control.defaultValue).length} items`;
443
447
  case "fontPicker":
444
448
  return asFontPickerValue(value).fontId;
445
449
  case "gradient":
@@ -494,6 +498,10 @@ function formatControlValueLabel(
494
498
  }
495
499
 
496
500
  function asColorValue(value: unknown): { hex: string } {
501
+ if (typeof value === "string") {
502
+ return { hex: value };
503
+ }
504
+
497
505
  if (isRecord(value)) {
498
506
  return { hex: asString(value.hex, "#C1FF00") };
499
507
  }
@@ -502,6 +510,10 @@ function asColorValue(value: unknown): { hex: string } {
502
510
  }
503
511
 
504
512
  function asColorOpacityValue(value: unknown): ColorOpacityValue {
513
+ if (typeof value === "string") {
514
+ return { hex: value, opacity: 100 };
515
+ }
516
+
505
517
  if (isRecord(value)) {
506
518
  return {
507
519
  hex: asString(value.hex, "#C1FF00"),
@@ -512,6 +524,84 @@ function asColorOpacityValue(value: unknown): ColorOpacityValue {
512
524
  return { hex: "#C1FF00", opacity: 100 };
513
525
  }
514
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
+
515
605
  function asGradientType(value: unknown): GradientType {
516
606
  return value === "linear" ||
517
607
  value === "radial" ||
@@ -1844,6 +1934,42 @@ export function ControlsPanel({
1844
1934
  return getKeyframeLabelAction(control, name, getControlValue(control));
1845
1935
  }
1846
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
+
1847
1973
  function renderColorGroup(
1848
1974
  entries: readonly ControlEntry[],
1849
1975
  headerKeyframeTarget: string | null,
@@ -1917,6 +2043,240 @@ export function ControlsPanel({
1917
2043
  );
1918
2044
  }
1919
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
+
1920
2280
  const visibleSections = resolvedControlsPanel.sections
1921
2281
  .map((section) => ({
1922
2282
  entries: getVisibleSectionEntries(section),
@@ -1963,6 +2323,23 @@ export function ControlsPanel({
1963
2323
  isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
1964
2324
  const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
1965
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;
1966
2343
  const inlineLayoutGroupByControlId = getInlineLayoutGroupByControlId({
1967
2344
  controlsById: visibleControls,
1968
2345
  layoutGroups: section.layoutGroups,
@@ -1970,11 +2347,7 @@ export function ControlsPanel({
1970
2347
 
1971
2348
  return (
1972
2349
  <PanelSection
1973
- action={
1974
- headerKeyframeEntry
1975
- ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
1976
- : undefined
1977
- }
2350
+ action={sectionHeaderAction}
1978
2351
  actionGroup={section.actionGroup}
1979
2352
  allowCompoundDividers={entries.length > 1}
1980
2353
  collapsed={isSectionCollapsed}
@@ -2190,6 +2563,9 @@ export function ControlsPanel({
2190
2563
  });
2191
2564
  }
2192
2565
 
2566
+ case "collectionActions":
2567
+ return renderCollectionActionsControl({ control, name, value });
2568
+
2193
2569
  case "curves": {
2194
2570
  const curvesName = control.label === false ? "Curves" : name;
2195
2571
 
@@ -535,6 +535,7 @@ 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",
@@ -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",
@@ -338,7 +339,12 @@ function createToolcraftAssembly({
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
  })
@@ -95,6 +95,7 @@ export type ToolcraftAssemblyCommand =
95
95
  | "canvas.zoomReset"
96
96
  | "controls.apply"
97
97
  | "controls.reset"
98
+ | "controls.resetTargets"
98
99
  | "controls.setValue"
99
100
  | "history.redo"
100
101
  | "history.undo"
@@ -292,6 +293,20 @@ export type ToolcraftColorOpacityValueSchema = {
292
293
  opacity?: number;
293
294
  };
294
295
 
296
+ export type ToolcraftCollectionItemControlSchema = {
297
+ commitMode?: "content" | "setting";
298
+ defaultValue?: unknown;
299
+ label?: boolean | string;
300
+ markerCount?: number;
301
+ max?: number;
302
+ min?: number;
303
+ options?: readonly { label: string; value: string }[];
304
+ step?: number;
305
+ type: string;
306
+ unit?: string;
307
+ variant?: string;
308
+ };
309
+
295
310
  export type ToolcraftFontPickerValueSchema = {
296
311
  color?: string;
297
312
  fontId: string;
@@ -308,23 +323,31 @@ export type ToolcraftCurveInterpolation = "monotone" | "smooth";
308
323
  export type ToolcraftControlSchema = {
309
324
  accept?: string;
310
325
  actions?: readonly (ToolcraftActionSchema | string)[];
326
+ addLabel?: string;
311
327
  commitMode?: "content" | "setting";
312
328
  defaultValue?: unknown;
313
329
  description?: string;
314
330
  disabled?: boolean;
315
331
  disabledWhen?: ToolcraftControlDisabledConditionSchema;
332
+ hardMaxItems?: number;
316
333
  interpolation?: ToolcraftCurveInterpolation;
317
334
  items?: readonly ToolcraftImagePickerItemSchema[];
335
+ itemControl?: ToolcraftCollectionItemControlSchema;
336
+ itemDefaultValue?: unknown;
337
+ itemLabel?: string;
318
338
  keyframeable?: boolean;
319
339
  label?: boolean | string;
320
340
  markerCount?: number;
321
341
  max?: number;
322
342
  min?: number;
343
+ minItems?: number;
323
344
  multiple?: boolean;
324
345
  orderRole?: ToolcraftControlOrderRole;
325
346
  performanceReason?: string;
326
347
  performanceRole?: ToolcraftControlPerformanceRole;
327
348
  options?: readonly { label: string; value: string }[];
349
+ recommendedMaxItems?: number;
350
+ removeLabel?: string;
328
351
  step?: number;
329
352
  target: string;
330
353
  type: string;
@@ -45,6 +45,59 @@ describe("toolcraftReducer", () => {
45
45
  expect(state.history.undo.at(-1)?.label).toBe("Reset controls");
46
46
  });
47
47
 
48
+ it("resets selected control targets to defaults and records one history patch", () => {
49
+ const app = defineToolcraft({
50
+ canvas: { enabled: false },
51
+ panels: {
52
+ controls: {
53
+ sections: [
54
+ {
55
+ controls: {
56
+ contrast: {
57
+ defaultValue: 22,
58
+ target: "style.contrast",
59
+ type: "slider",
60
+ },
61
+ opacity: {
62
+ defaultValue: 75,
63
+ target: "selectedLayer.opacity",
64
+ type: "slider",
65
+ },
66
+ },
67
+ title: "Tone",
68
+ },
69
+ ],
70
+ title: "Controls",
71
+ },
72
+ },
73
+ });
74
+ const changedOpacity = toolcraftReducer(createToolcraftState(app), {
75
+ target: "selectedLayer.opacity",
76
+ type: "controls.setValue",
77
+ value: 12,
78
+ });
79
+ const changedBoth = toolcraftReducer(changedOpacity, {
80
+ target: "style.contrast",
81
+ type: "controls.setValue",
82
+ value: 9,
83
+ });
84
+
85
+ const state = toolcraftReducer(changedBoth, {
86
+ label: "Reset Tone section",
87
+ targets: ["selectedLayer.opacity"],
88
+ type: "controls.resetTargets",
89
+ });
90
+
91
+ expect(state.values["selectedLayer.opacity"]).toBe(75);
92
+ expect(state.values["style.contrast"]).toBe(9);
93
+ expect(state.history.undo).toHaveLength(3);
94
+ expect(state.history.undo.at(-1)).toMatchObject({
95
+ after: { "selectedLayer.opacity": 75 },
96
+ before: { "selectedLayer.opacity": 12 },
97
+ label: "Reset Tone section",
98
+ });
99
+ });
100
+
48
101
  it("updates canvas size and records history", () => {
49
102
  const size = { width: 1200, height: 900, unit: "px" } as const;
50
103
 
@@ -212,6 +212,17 @@ function getResetCanvasSize(
212
212
  };
213
213
  }
214
214
 
215
+ function canvasSizesEqual(
216
+ first: ToolcraftState["canvas"]["size"],
217
+ second: ToolcraftState["canvas"]["size"],
218
+ ): boolean {
219
+ return (
220
+ first.height === second.height &&
221
+ first.unit === second.unit &&
222
+ first.width === second.width
223
+ );
224
+ }
225
+
215
226
  function clampTimelineDuration(value: number): number {
216
227
  if (!Number.isFinite(value)) {
217
228
  return minTimelineDurationSeconds;
@@ -712,6 +723,42 @@ export function toolcraftReducer(
712
723
  );
713
724
  }
714
725
 
726
+ case "controls.resetTargets": {
727
+ const targetSet = new Set(command.targets);
728
+ const before: Record<string, unknown> = {};
729
+ const after: Record<string, unknown> = {};
730
+
731
+ for (const target of targetSet) {
732
+ if (!(target in state.defaults) || Object.is(state.values[target], state.defaults[target])) {
733
+ continue;
734
+ }
735
+
736
+ before[target] = state.values[target];
737
+ after[target] = state.defaults[target];
738
+ }
739
+
740
+ const resetCanvasSize = getResetCanvasSize(state);
741
+ const shouldResetCanvasSize =
742
+ resetCanvasSize !== null &&
743
+ (targetSet.has(canvasSizeWidthTarget) || targetSet.has(canvasSizeHeightTarget)) &&
744
+ !canvasSizesEqual(state.canvas.size, resetCanvasSize);
745
+
746
+ if (shouldResetCanvasSize) {
747
+ before["canvas.size"] = state.canvas.size;
748
+ after["canvas.size"] = resetCanvasSize;
749
+ }
750
+
751
+ if (Object.keys(after).length === 0) {
752
+ return state;
753
+ }
754
+
755
+ return commitStatePatch(state, {
756
+ after,
757
+ before,
758
+ label: command.label ?? "Reset section",
759
+ });
760
+ }
761
+
715
762
  case "layers.add": {
716
763
  const layer = createLayer(state, command.layer);
717
764
  const insertIndex = clampInsertIndex(state.layers.length, command.insertIndex);
@@ -11,6 +11,7 @@ export type ToolcraftCommand =
11
11
  }
12
12
  | { type: "controls.apply" }
13
13
  | { type: "controls.reset" }
14
+ | { label?: string; targets: string[]; type: "controls.resetTargets" }
14
15
  | { insertIndex?: number; layer?: ToolcraftLayerDraft; type: "layers.add" }
15
16
  | { layerId: string; type: "layers.delete" }
16
17
  | { layerIds: string[]; parentGroupId: string | null; type: "layers.moveToGroup" }
@@ -82,6 +83,7 @@ export const toolcraftRuntimeCommandTypes = [
82
83
  "controls.setValue",
83
84
  "controls.apply",
84
85
  "controls.reset",
86
+ "controls.resetTargets",
85
87
  "layers.add",
86
88
  "layers.delete",
87
89
  "layers.moveToGroup",