@pixel-point/toolcraft 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +84 -12
  3. package/templates/runtime/contracts/component-contracts.ts +40 -17
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +31 -0
  6. package/templates/runtime/export/export.ts +41 -0
  7. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  8. package/templates/runtime/react/canvas-shell.tsx +178 -23
  9. package/templates/runtime/react/control-conditions.ts +166 -0
  10. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  11. package/templates/runtime/react/controls-panel.test.tsx +404 -8
  12. package/templates/runtime/react/controls-panel.tsx +90 -4
  13. package/templates/runtime/react/media-file.ts +19 -0
  14. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  15. package/templates/runtime/schema/define-toolcraft.ts +4 -2
  16. package/templates/runtime/schema/types.ts +4 -0
  17. package/templates/runtime/state/reducer.test.ts +304 -0
  18. package/templates/runtime/state/reducer.ts +148 -9
  19. package/templates/runtime/state/types.ts +10 -2
  20. package/templates/runtime/testing/performance.test.ts +1282 -48
  21. package/templates/runtime/testing/performance.ts +676 -39
  22. package/templates/starter/AGENTS.md +6 -5
  23. package/templates/starter/docs/toolcraft/acceptance-testing.md +11 -5
  24. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  25. package/templates/starter/docs/toolcraft/component-rules.md +16 -7
  26. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  27. package/templates/starter/docs/toolcraft/performance.md +47 -5
  28. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  29. package/templates/starter/docs/toolcraft/schema-reference.md +6 -6
  30. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  31. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  32. package/templates/starter/e2e/performance-helpers.ts +197 -0
  33. package/templates/starter/package.json +3 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  35. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  36. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  37. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  38. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  39. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  40. package/templates/ui/components/controls/index.ts +1 -0
  41. package/templates/ui/components/panel/panel-section.tsx +53 -1
@@ -248,6 +248,9 @@ function StateProbe() {
248
248
  {state.canvas.size.width},{state.canvas.size.height}
249
249
  </span>
250
250
  <span data-testid="media-count">{state.mediaAssets.length}</span>
251
+ <span data-testid="media-ids">
252
+ {state.mediaAssets.map((asset) => asset.id).join(",")}
253
+ </span>
251
254
  <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
252
255
  <span data-testid="timeline-keyframes">
253
256
  {JSON.stringify(state.timeline.keyframeGroups)}
@@ -384,6 +387,46 @@ describe("ControlsPanel", () => {
384
387
  expect(inlineGroup?.textContent).toContain("Snap Y");
385
388
  });
386
389
 
390
+ it("keeps wrapped local action buttons left aligned below their label", () => {
391
+ const schema = defineToolcraft({
392
+ canvas: { enabled: false },
393
+ panels: {
394
+ controls: {
395
+ sections: [
396
+ {
397
+ controls: {
398
+ masks: {
399
+ actions: [
400
+ { label: "Rect", value: "mask.add.rectangle" },
401
+ { label: "Circle", value: "mask.add.circle" },
402
+ { label: "Tri", value: "mask.add.triangle" },
403
+ { label: "Delete", value: "mask.delete" },
404
+ ],
405
+ defaultValue: [],
406
+ label: "Masks",
407
+ target: "composition.masks",
408
+ type: "actions",
409
+ },
410
+ },
411
+ title: "Composition",
412
+ },
413
+ ],
414
+ title: "Controls",
415
+ },
416
+ },
417
+ });
418
+ const { container } = renderControlsPanelWithSchema(schema);
419
+ const actionField = container.querySelector('[data-slot="actions-control"]');
420
+ const actionButtons = container.querySelector(
421
+ '[data-slot="actions-control-buttons"]',
422
+ );
423
+
424
+ expect(actionField?.className).toContain("justify-between");
425
+ expect(actionField?.className).toContain("flex-wrap");
426
+ expect(actionButtons?.className).toContain("justify-start");
427
+ expect(actionButtons?.className).not.toContain("ml-auto");
428
+ });
429
+
387
430
  it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
388
431
  const schema = defineToolcraft({
389
432
  canvas: { enabled: false },
@@ -448,35 +491,35 @@ describe("ControlsPanel", () => {
448
491
  defaultValue: { hex: "#DFFF1A" },
449
492
  description: "Sets the first bead color.",
450
493
  label: "Color 1",
451
- target: "beads.color1",
494
+ target: "palette.accent1",
452
495
  type: "color",
453
496
  },
454
497
  color2: {
455
498
  defaultValue: { hex: "#8CFF3A" },
456
499
  description: "Sets the second bead color.",
457
500
  label: "Color 2",
458
- target: "beads.color2",
501
+ target: "palette.accent2",
459
502
  type: "color",
460
503
  },
461
504
  color3: {
462
505
  defaultValue: { hex: "#F4FF5A" },
463
506
  description: "Sets the third bead color.",
464
507
  label: "Color 3",
465
- target: "beads.color3",
508
+ target: "palette.accent3",
466
509
  type: "color",
467
510
  },
468
511
  color4: {
469
512
  defaultValue: { hex: "#B8FF2E" },
470
513
  description: "Sets the fourth bead color.",
471
514
  label: "Color 4",
472
- target: "beads.color4",
515
+ target: "palette.accent4",
473
516
  type: "color",
474
517
  },
475
518
  color5: {
476
519
  defaultValue: { hex: "#ECFF68" },
477
520
  description: "Sets the fifth bead color.",
478
521
  label: "Color 5",
479
- target: "beads.color5",
522
+ target: "palette.accent5",
480
523
  type: "color",
481
524
  },
482
525
  colorSpread: {
@@ -486,7 +529,7 @@ describe("ControlsPanel", () => {
486
529
  label: "Spread",
487
530
  max: 100,
488
531
  min: 0,
489
- target: "beads.colorSpread",
532
+ target: "palette.spread",
490
533
  type: "slider",
491
534
  unit: "%",
492
535
  },
@@ -503,7 +546,7 @@ describe("ControlsPanel", () => {
503
546
  layout: "inline",
504
547
  },
505
548
  ],
506
- title: "Bead Colors",
549
+ title: "Accent Shades",
507
550
  },
508
551
  {
509
552
  controls: {
@@ -1102,7 +1145,6 @@ describe("ControlsPanel", () => {
1102
1145
  controls: {
1103
1146
  font: {
1104
1147
  defaultValue: {
1105
- color: "#FFFFFF",
1106
1148
  fontId: "inter",
1107
1149
  fontSize: 16,
1108
1150
  fontWeight: "400",
@@ -2501,6 +2543,22 @@ describe("ControlsPanel", () => {
2501
2543
  );
2502
2544
  expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2503
2545
  expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2546
+ expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2");
2547
+
2548
+ const firstPreview = screen
2549
+ .getByRole("img", { name: "one.svg" })
2550
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2551
+ const secondPreview = screen
2552
+ .getByRole("img", { name: "two.svg" })
2553
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2554
+
2555
+ if (!firstPreview || !secondPreview) {
2556
+ throw new Error("Expected multi-image previews to render");
2557
+ }
2558
+
2559
+ expect(firstPreview.getAttribute("role")).toBe("button");
2560
+ expect(firstPreview.getAttribute("tabindex")).toBe("0");
2561
+ expect(firstPreview.className).toContain("cursor-grab");
2504
2562
 
2505
2563
  fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2506
2564
 
@@ -2508,6 +2566,118 @@ describe("ControlsPanel", () => {
2508
2566
  expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2509
2567
  });
2510
2568
 
2569
+ it("renders arbitrary file uploads as a sortable file list", () => {
2570
+ const schema = defineToolcraft({
2571
+ canvas: { enabled: true, upload: true },
2572
+ panels: {
2573
+ controls: {
2574
+ sections: [
2575
+ {
2576
+ controls: {
2577
+ source: {
2578
+ assetKind: "file",
2579
+ label: "Files",
2580
+ multiple: true,
2581
+ target: "input.files",
2582
+ type: "fileDrop",
2583
+ },
2584
+ },
2585
+ layout: "standalone",
2586
+ title: "Input",
2587
+ },
2588
+ ],
2589
+ title: "Generation Controls",
2590
+ },
2591
+ },
2592
+ });
2593
+
2594
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2595
+ layers: [
2596
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2597
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2598
+ ],
2599
+ mediaAssets: [
2600
+ {
2601
+ assetKind: "file",
2602
+ dataUrl: "data:text/plain;base64,b25l",
2603
+ fileName: "one.txt",
2604
+ id: "media-1",
2605
+ layerId: "layer-1",
2606
+ mimeType: "text/plain",
2607
+ position: { x: 0, y: 0 },
2608
+ sourceTarget: "input.files",
2609
+ },
2610
+ {
2611
+ assetKind: "file",
2612
+ dataUrl: "data:application/json;base64,e30=",
2613
+ fileName: "two.json",
2614
+ id: "media-2",
2615
+ layerId: "layer-2",
2616
+ mimeType: "application/json",
2617
+ position: { x: 0, y: 0 },
2618
+ sourceTarget: "input.files",
2619
+ },
2620
+ ],
2621
+ selectedLayerId: "layer-2",
2622
+ });
2623
+
2624
+ const fileList = container.querySelector('[data-slot="file-upload-file-list"]');
2625
+ const fileRows = container.querySelectorAll('[data-slot="file-upload-file-item"]');
2626
+ const fileDividers = container.querySelectorAll('[data-slot="file-upload-file-divider"]');
2627
+
2628
+ expect(fileList).toBeTruthy();
2629
+ expect(fileRows).toHaveLength(2);
2630
+ expect(fileDividers).toHaveLength(2);
2631
+ expect(screen.getByText("one.txt")).toBeTruthy();
2632
+ expect(screen.getByText("two.json")).toBeTruthy();
2633
+ expect(screen.getByText("one.txt").className).toContain("text-xs");
2634
+ expect(screen.getByText("one.txt").className).not.toContain("truncate");
2635
+ expect(screen.getByText("one.txt").className).toContain("mask-image");
2636
+ expect(screen.getByRole("button", { name: "Remove one.txt" })).toBeTruthy();
2637
+ expect(screen.getByRole("button", { name: "Add a new file" })).toBeTruthy();
2638
+ expect(fileRows[0]?.className).not.toContain("border-b");
2639
+ expect(fileRows[0]?.className).toContain("hover:bg");
2640
+ expect(fileDividers[0]?.className).toContain("var(--border)_5%");
2641
+ expect(fileRows[0]?.getAttribute("role")).toBe("button");
2642
+ expect(fileRows[0]?.getAttribute("tabindex")).toBe("0");
2643
+
2644
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.json" }));
2645
+
2646
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2647
+ expect(screen.queryByText("two.json")).toBeNull();
2648
+ });
2649
+
2650
+ it("renders the arbitrary file uploader empty state", () => {
2651
+ const schema = defineToolcraft({
2652
+ canvas: { enabled: true, upload: true },
2653
+ panels: {
2654
+ controls: {
2655
+ sections: [
2656
+ {
2657
+ controls: {
2658
+ source: {
2659
+ assetKind: "file",
2660
+ label: "Files",
2661
+ target: "input.files",
2662
+ type: "fileDrop",
2663
+ },
2664
+ },
2665
+ layout: "standalone",
2666
+ title: "Input",
2667
+ },
2668
+ ],
2669
+ title: "Generation Controls",
2670
+ },
2671
+ },
2672
+ });
2673
+
2674
+ renderControlsPanelWithSchema(schema);
2675
+
2676
+ expect(screen.getByText("Click to upload a file")).toBeTruthy();
2677
+ expect(screen.getByText("or drag it onto the canvas")).toBeTruthy();
2678
+ expect(screen.queryByText("Click to upload an image")).toBeNull();
2679
+ });
2680
+
2511
2681
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2512
2682
  const schema = defineToolcraft({
2513
2683
  canvas: { enabled: true, upload: true },
@@ -2574,6 +2744,65 @@ describe("ControlsPanel", () => {
2574
2744
  expect(colorControlList?.children).toHaveLength(1);
2575
2745
  });
2576
2746
 
2747
+ it("keeps an odd trailing plain color half-width in a color bank", () => {
2748
+ const schema = defineToolcraft({
2749
+ canvas: { enabled: true },
2750
+ panels: {
2751
+ controls: {
2752
+ sections: [
2753
+ {
2754
+ controls: {
2755
+ accent1: {
2756
+ defaultValue: { hex: "#9CE6FF" },
2757
+ label: "Accent 1",
2758
+ target: "accent.shade1",
2759
+ type: "color",
2760
+ },
2761
+ accent2: {
2762
+ defaultValue: { hex: "#FF7A90" },
2763
+ label: "Accent 2",
2764
+ target: "accent.shade2",
2765
+ type: "color",
2766
+ },
2767
+ accent3: {
2768
+ defaultValue: { hex: "#FFD166" },
2769
+ label: "Accent 3",
2770
+ target: "accent.shade3",
2771
+ type: "color",
2772
+ },
2773
+ accent4: {
2774
+ defaultValue: { hex: "#8BF38B" },
2775
+ label: "Accent 4",
2776
+ target: "accent.shade4",
2777
+ type: "color",
2778
+ },
2779
+ accent5: {
2780
+ defaultValue: { hex: "#BDA8FF" },
2781
+ label: "Accent 5",
2782
+ target: "accent.shade5",
2783
+ type: "color",
2784
+ },
2785
+ },
2786
+ title: "Accent Shades",
2787
+ },
2788
+ ],
2789
+ title: "Generation Controls",
2790
+ },
2791
+ },
2792
+ });
2793
+
2794
+ const { container } = renderControlsPanelWithSchema(schema);
2795
+ const pairedColorRows = container.querySelectorAll('[data-slot="color-control-grid"]');
2796
+ const trailingColorButtonGroup = screen
2797
+ .getByLabelText("Accent 5 hex")
2798
+ .closest('[data-slot="button-group"]');
2799
+ const trailingColorWidthContainer = trailingColorButtonGroup?.parentElement;
2800
+
2801
+ expect(pairedColorRows).toHaveLength(2);
2802
+ expect(trailingColorWidthContainer?.className).toContain("w-1/2");
2803
+ expect(trailingColorWidthContainer?.className).not.toContain("w-full");
2804
+ });
2805
+
2577
2806
  it("omits visible color field labels only inside color-only sections", () => {
2578
2807
  const schema = defineToolcraft({
2579
2808
  canvas: { enabled: true },
@@ -3341,6 +3570,54 @@ describe("ControlsPanel", () => {
3341
3570
  expect(container.querySelector('[data-control-section-reset-button]')).toBeTruthy();
3342
3571
  });
3343
3572
 
3573
+ it("clears file upload previews from the selected section reset action", () => {
3574
+ const schema = defineToolcraft({
3575
+ canvas: { enabled: true, upload: true },
3576
+ panels: {
3577
+ controls: {
3578
+ sections: [
3579
+ {
3580
+ controls: {
3581
+ source: {
3582
+ defaultValue: null,
3583
+ label: "Image",
3584
+ target: "media.source",
3585
+ type: "fileDrop",
3586
+ },
3587
+ },
3588
+ title: "Source Image",
3589
+ },
3590
+ ],
3591
+ title: "Controls",
3592
+ },
3593
+ },
3594
+ });
3595
+
3596
+ renderControlsPanelWithSchema(schema, {}, {
3597
+ mediaAssets: [
3598
+ {
3599
+ dataUrl:
3600
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E",
3601
+ fileName: "source.svg",
3602
+ id: "media-1",
3603
+ layerId: "layer-1",
3604
+ mimeType: "image/svg+xml",
3605
+ position: { x: 0, y: 0 },
3606
+ size: { height: 300, unit: "px", width: 400 },
3607
+ sourceTarget: "media.source",
3608
+ },
3609
+ ],
3610
+ });
3611
+
3612
+ expect(screen.getByRole("img", { name: "source.svg" })).toBeTruthy();
3613
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3614
+
3615
+ fireEvent.click(screen.getByRole("button", { name: "Reset Source Image section" }));
3616
+
3617
+ expect(screen.queryByRole("img", { name: "source.svg" })).toBeNull();
3618
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
3619
+ });
3620
+
3344
3621
  it("renders collection actions as add/remove controls for runtime item arrays", () => {
3345
3622
  const schema = defineToolcraft({
3346
3623
  canvas: { enabled: false },
@@ -3449,6 +3726,101 @@ describe("ControlsPanel", () => {
3449
3726
  expect(screen.getByDisplayValue("New label")).toBeTruthy();
3450
3727
  });
3451
3728
 
3729
+ it("keeps a collection font picker color popover open while editing its color surface", async () => {
3730
+ const schema = defineToolcraft({
3731
+ canvas: { enabled: false },
3732
+ panels: {
3733
+ controls: {
3734
+ sections: [
3735
+ {
3736
+ controls: {
3737
+ textStyles: {
3738
+ defaultValue: [
3739
+ {
3740
+ color: "#FFFFFF",
3741
+ fontId: "inter",
3742
+ fontSize: 16,
3743
+ fontWeight: "400",
3744
+ letterSpacing: "normal",
3745
+ lineHeight: "normal",
3746
+ opacity: 100,
3747
+ textCase: "original",
3748
+ },
3749
+ ],
3750
+ itemControl: {
3751
+ defaultValue: {
3752
+ color: "#FFFFFF",
3753
+ fontId: "inter",
3754
+ fontSize: 16,
3755
+ fontWeight: "400",
3756
+ letterSpacing: "normal",
3757
+ lineHeight: "normal",
3758
+ opacity: 100,
3759
+ textCase: "original",
3760
+ },
3761
+ label: "Font",
3762
+ type: "fontPicker",
3763
+ },
3764
+ label: "Text styles",
3765
+ minItems: 1,
3766
+ target: "text.styles",
3767
+ type: "collectionActions",
3768
+ },
3769
+ },
3770
+ title: "Typography",
3771
+ },
3772
+ ],
3773
+ title: "Controls",
3774
+ },
3775
+ },
3776
+ });
3777
+
3778
+ const { container } = renderControlsPanelWithSchema(schema);
3779
+
3780
+ fireEvent.click(screen.getByRole("button", { name: "Pick Color" }));
3781
+
3782
+ const surface = await waitFor(() => {
3783
+ const nextSurface = container.ownerDocument.body.querySelector<HTMLElement>(
3784
+ '[data-slot="style-guide-color-surface"]',
3785
+ );
3786
+ if (!nextSurface) {
3787
+ throw new Error("Expected color surface to be open.");
3788
+ }
3789
+ return nextSurface;
3790
+ });
3791
+
3792
+ const surfaceRect = {
3793
+ bottom: 100,
3794
+ height: 100,
3795
+ left: 0,
3796
+ right: 100,
3797
+ toJSON: () => ({}),
3798
+ top: 0,
3799
+ width: 100,
3800
+ x: 0,
3801
+ y: 0,
3802
+ } as DOMRect;
3803
+ Object.defineProperty(surface, "getBoundingClientRect", {
3804
+ configurable: true,
3805
+ value: () => surfaceRect,
3806
+ });
3807
+
3808
+ fireEvent.pointerDown(surface, {
3809
+ buttons: 1,
3810
+ clientX: 72,
3811
+ clientY: 28,
3812
+ pointerId: 1,
3813
+ });
3814
+
3815
+ expect(
3816
+ container.ownerDocument.body.querySelector('[data-slot="style-guide-color-surface"]'),
3817
+ ).toBeTruthy();
3818
+
3819
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3820
+
3821
+ expect(values["text.styles"][0].color).not.toBe("#FFFFFF");
3822
+ });
3823
+
3452
3824
  it("commits canvas size controls to runtime state on blur or Enter", () => {
3453
3825
  const { container } = renderControlsPanel();
3454
3826
 
@@ -4017,6 +4389,30 @@ describe("ControlsPanel", () => {
4017
4389
  expect(container.querySelector('[data-slot="control-section-header"]')).toBeTruthy();
4018
4390
  });
4019
4391
 
4392
+ it("suppresses child control transitions when a section re-expands", () => {
4393
+ const schema = createSchema();
4394
+ renderControlsPanelWithSchema(schema);
4395
+ const basicTitle = screen.getByText("Basic");
4396
+ const header = basicTitle.closest<HTMLElement>('[data-slot="control-section-header"]');
4397
+ const section = basicTitle.closest("section");
4398
+ const initialBody = section?.querySelector<HTMLElement>(
4399
+ '[data-slot="panel-section-collapsible-body"]',
4400
+ );
4401
+
4402
+ fireEvent.click(header!);
4403
+ fireEvent.transitionEnd(initialBody!);
4404
+ fireEvent.click(header!);
4405
+
4406
+ const expandedBody = section?.querySelector<HTMLElement>(
4407
+ '[data-slot="panel-section-collapsible-body"]',
4408
+ );
4409
+ const innerControls = expandedBody?.firstElementChild as HTMLElement | null;
4410
+
4411
+ expect(expandedBody?.className).toContain("transition-[grid-template-rows,opacity]");
4412
+ expect(expandedBody?.className).toContain("grid-rows-[1fr]");
4413
+ expect(innerControls?.getAttribute("data-toolcraft-controls-mounting")).toBe("true");
4414
+ });
4415
+
4020
4416
  it("persists ordinary section collapse state as a UI preference across remounts", () => {
4021
4417
  const schema = createSchema();
4022
4418
  const { unmount } = renderControlsPanelWithSchema(schema);
@@ -70,7 +70,11 @@ import type {
70
70
  ToolcraftPanelState,
71
71
  ToolcraftState,
72
72
  } from "../state/types";
73
- import { readImportedImageFile } from "./media-file";
73
+ import {
74
+ isToolcraftImageFile,
75
+ readImportedFile,
76
+ readImportedImageFile,
77
+ } from "./media-file";
74
78
  import { PanelContainer } from "./panel-host";
75
79
  import type { PanelPlacement, PanelStateChange } from "./panel-host-types";
76
80
  import type { ToolcraftControlRendererMap } from "./control-renderers";
@@ -585,6 +589,8 @@ function getCollectionItemDefaultValue(control: ToolcraftControlSchema): unknown
585
589
  return { hex: "#C1FF00" };
586
590
  case "colorOpacity":
587
591
  return { hex: "#C1FF00", opacity: 100 };
592
+ case "fontPicker":
593
+ return asFontPickerValue(control.itemControl?.defaultValue);
588
594
  case "checkbox":
589
595
  case "switch":
590
596
  return false;
@@ -1790,7 +1796,9 @@ export function ControlsPanel({
1790
1796
  }
1791
1797
 
1792
1798
  function isColorSectionTitle(sectionTitle: string | undefined): boolean {
1793
- 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
+ );
1794
1802
  }
1795
1803
 
1796
1804
  function isSequentialColorLabel(label: string): boolean {
@@ -2221,6 +2229,16 @@ export function ControlsPanel({
2221
2229
  showLabel={itemControl?.label !== false}
2222
2230
  />
2223
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
+ );
2224
2242
  case "text":
2225
2243
  return (
2226
2244
  <TextInput
@@ -2590,15 +2608,51 @@ export function ControlsPanel({
2590
2608
  }
2591
2609
 
2592
2610
  case "fileDrop": {
2593
- 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
+ );
2594
2619
  const previewMediaAsset = previewMediaAssets[0];
2595
2620
  const previews = previewMediaAssets.map((asset): FileDropPreview => ({
2596
2621
  alt: asset.fileName,
2622
+ assetKind: asset.assetKind ?? "image",
2623
+ fileName: asset.fileName,
2597
2624
  id: asset.id,
2598
2625
  size: asset.size,
2599
2626
  src: asset.dataUrl,
2600
2627
  }));
2628
+ const previewMediaIds = previewMediaAssets.map((asset) => asset.id);
2601
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
+
2602
2656
  void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2603
2657
  if (!importedImage) {
2604
2658
  return;
@@ -2606,11 +2660,13 @@ export function ControlsPanel({
2606
2660
 
2607
2661
  dispatchCommand({
2608
2662
  asset: {
2663
+ assetKind: "image",
2609
2664
  dataUrl: importedImage.dataUrl,
2610
2665
  fileName: file.name,
2611
2666
  mimeType: file.type || "image/*",
2612
2667
  position: { x: 0, y: 0 },
2613
2668
  size: importedImage.size,
2669
+ sourceTarget: control.target,
2614
2670
  },
2615
2671
  replaceExisting,
2616
2672
  type: "media.import",
@@ -2620,7 +2676,11 @@ export function ControlsPanel({
2620
2676
 
2621
2677
  return (
2622
2678
  <FileDrop
2623
- 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}
2624
2684
  multiple={control.multiple}
2625
2685
  key={id}
2626
2686
  onClear={
@@ -2647,6 +2707,32 @@ export function ControlsPanel({
2647
2707
  type: "media.delete",
2648
2708
  });
2649
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
+ }}
2650
2736
  preview={
2651
2737
  previewMediaAsset
2652
2738
  ? {