@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
@@ -3,9 +3,22 @@
3
3
  import * as React from "react";
4
4
 
5
5
  import { clampToolcraftCanvasZoom } from "../state/canvas-zoom";
6
- import type { ToolcraftPoint } from "../state/types";
6
+ import type {
7
+ ToolcraftMediaAsset,
8
+ ToolcraftPoint,
9
+ ToolcraftState,
10
+ } from "../state/types";
11
+ import type { ToolcraftControlSchema } from "../schema/types";
12
+ import {
13
+ isToolcraftControlVisible,
14
+ isToolcraftSectionVisible,
15
+ } from "./control-conditions";
7
16
  import { isToolcraftLayerVisibleInTree } from "./layer-tree";
8
- import { readImportedImageFile } from "./media-file";
17
+ import {
18
+ isToolcraftImageFile,
19
+ readImportedFile,
20
+ readImportedImageFile,
21
+ } from "./media-file";
9
22
  import { useToolcraft } from "./use-toolcraft";
10
23
 
11
24
  export type CanvasShellProps = {
@@ -20,6 +33,9 @@ type CanvasDragState = {
20
33
  startX: number;
21
34
  startY: number;
22
35
  };
36
+ type ToolcraftCanvasImageAsset = ToolcraftMediaAsset & {
37
+ size: NonNullable<ToolcraftMediaAsset["size"]>;
38
+ };
23
39
 
24
40
  const wheelZoomSensitivity = 0.12;
25
41
  const wheelPinchZoomSensitivity = 0.5;
@@ -28,13 +44,6 @@ function cn(...classNames: Array<string | false | null | undefined>): string {
28
44
  return classNames.filter(Boolean).join(" ");
29
45
  }
30
46
 
31
- function isImageFile(file: File): boolean {
32
- return (
33
- file.type.startsWith("image/") ||
34
- /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(file.name)
35
- );
36
- }
37
-
38
47
  function isDragLeavingCurrentTarget(
39
48
  event: React.DragEvent<HTMLElement>,
40
49
  ): boolean {
@@ -79,6 +88,94 @@ function getNextWheelZoom(
79
88
  return clampToolcraftCanvasZoom(currentZoom + zoomDelta);
80
89
  }
81
90
 
91
+ function getFileExtension(file: File): string {
92
+ const match = /\.([a-z0-9]+)$/iu.exec(file.name);
93
+
94
+ return match?.[1]?.toLowerCase() ?? "";
95
+ }
96
+
97
+ function controlAcceptsFile(control: ToolcraftControlSchema, file: File): boolean {
98
+ const accept = control.accept?.trim();
99
+
100
+ if (!accept) {
101
+ return control.assetKind === "file" || isToolcraftImageFile(file);
102
+ }
103
+
104
+ const fileExtension = getFileExtension(file);
105
+ const mimeType = file.type.toLowerCase();
106
+
107
+ return accept.split(",").some((rawToken) => {
108
+ const token = rawToken.trim().toLowerCase();
109
+
110
+ if (!token) {
111
+ return false;
112
+ }
113
+
114
+ if (token.startsWith(".")) {
115
+ return token.slice(1) === fileExtension;
116
+ }
117
+
118
+ if (token.endsWith("/*")) {
119
+ const prefix = token.slice(0, -1);
120
+
121
+ return mimeType.startsWith(prefix);
122
+ }
123
+
124
+ if (token.includes("/")) {
125
+ return mimeType === token;
126
+ }
127
+
128
+ return token === fileExtension;
129
+ });
130
+ }
131
+
132
+ function getVisibleFileDropControls(state: ToolcraftState): ToolcraftControlSchema[] {
133
+ return (state.schema.panels.controls?.sections ?? []).flatMap((section) => {
134
+ if (!isToolcraftSectionVisible(state, section)) {
135
+ return [];
136
+ }
137
+
138
+ return Object.values(section.controls).filter(
139
+ (control) =>
140
+ control.type === "fileDrop" && isToolcraftControlVisible(state, control),
141
+ );
142
+ });
143
+ }
144
+
145
+ function getCanvasDropTarget(
146
+ state: ToolcraftState,
147
+ file: File,
148
+ ): ToolcraftControlSchema | null {
149
+ const isImage = isToolcraftImageFile(file);
150
+ const controls = getVisibleFileDropControls(state).filter((control) =>
151
+ controlAcceptsFile(control, file),
152
+ );
153
+ const preferredAssetKind = isImage ? "image" : "file";
154
+ const exactMatch = controls.find(
155
+ (control) =>
156
+ (control.assetKind === "file" ? "file" : "image") === preferredAssetKind,
157
+ );
158
+
159
+ if (exactMatch) {
160
+ return exactMatch;
161
+ }
162
+
163
+ return isImage
164
+ ? (controls.find((control) => control.assetKind === "file") ?? null)
165
+ : null;
166
+ }
167
+
168
+ function isDefaultCanvasImageAsset(
169
+ state: ToolcraftState,
170
+ mediaAsset: ToolcraftMediaAsset,
171
+ ): mediaAsset is ToolcraftCanvasImageAsset {
172
+ return (
173
+ (mediaAsset.assetKind ?? "image") === "image" &&
174
+ mediaAsset.size !== undefined &&
175
+ isToolcraftLayerVisibleInTree(state.layers, mediaAsset.layerId)
176
+ );
177
+ }
178
+
82
179
  function getZoomedCanvasOffset({
83
180
  clientX,
84
181
  clientY,
@@ -122,7 +219,7 @@ export function CanvasShell({
122
219
  const visibleMediaAssets = React.useMemo(
123
220
  () =>
124
221
  state.mediaAssets.filter((mediaAsset) =>
125
- isToolcraftLayerVisibleInTree(state.layers, mediaAsset.layerId),
222
+ isDefaultCanvasImageAsset(state, mediaAsset),
126
223
  ),
127
224
  [state.layers, state.mediaAssets],
128
225
  );
@@ -279,28 +376,86 @@ export function CanvasShell({
279
376
  event.preventDefault();
280
377
  setDragOver(false);
281
378
 
282
- const uploadedFile = Array.from(event.dataTransfer?.files ?? [])[0];
379
+ const uploadedFiles = Array.from(event.dataTransfer?.files ?? []);
283
380
 
284
- if (!uploadedFile || !isImageFile(uploadedFile)) {
381
+ if (uploadedFiles.length === 0) {
285
382
  return;
286
383
  }
287
384
 
288
385
  const position = getCanvasPositionFromEvent(event, offset, zoom);
386
+ const hasFileDropControls = getVisibleFileDropControls(state).length > 0;
387
+
388
+ uploadedFiles.forEach((uploadedFile) => {
389
+ const targetControl = getCanvasDropTarget(state, uploadedFile);
289
390
 
290
- void readImportedImageFile(uploadedFile, size).then((importedImage) => {
291
- if (!importedImage) {
391
+ if (!targetControl) {
392
+ if (hasFileDropControls || !isToolcraftImageFile(uploadedFile)) {
393
+ return;
394
+ }
395
+
396
+ void readImportedImageFile(uploadedFile, size).then((importedImage) => {
397
+ if (!importedImage) {
398
+ return;
399
+ }
400
+
401
+ dispatch({
402
+ asset: {
403
+ assetKind: "image",
404
+ dataUrl: importedImage.dataUrl,
405
+ fileName: uploadedFile.name,
406
+ mimeType: uploadedFile.type || "image/*",
407
+ position,
408
+ size: importedImage.size,
409
+ },
410
+ type: "media.import",
411
+ });
412
+ });
292
413
  return;
293
414
  }
294
415
 
295
- dispatch({
296
- asset: {
297
- dataUrl: importedImage.dataUrl,
298
- fileName: uploadedFile.name,
299
- mimeType: uploadedFile.type || "image/*",
300
- position,
301
- size: importedImage.size,
302
- },
303
- type: "media.import",
416
+ const assetKind = targetControl.assetKind === "file" ? "file" : "image";
417
+ const replaceExisting = targetControl.multiple !== true;
418
+
419
+ if (assetKind === "file") {
420
+ void readImportedFile(uploadedFile).then((importedFile) => {
421
+ if (!importedFile) {
422
+ return;
423
+ }
424
+
425
+ dispatch({
426
+ asset: {
427
+ assetKind: "file",
428
+ dataUrl: importedFile.dataUrl,
429
+ fileName: uploadedFile.name,
430
+ mimeType: uploadedFile.type || "application/octet-stream",
431
+ position: { x: 0, y: 0 },
432
+ sourceTarget: targetControl.target,
433
+ },
434
+ replaceExisting,
435
+ type: "media.import",
436
+ });
437
+ });
438
+ return;
439
+ }
440
+
441
+ void readImportedImageFile(uploadedFile, size).then((importedImage) => {
442
+ if (!importedImage) {
443
+ return;
444
+ }
445
+
446
+ dispatch({
447
+ asset: {
448
+ assetKind: "image",
449
+ dataUrl: importedImage.dataUrl,
450
+ fileName: uploadedFile.name,
451
+ mimeType: uploadedFile.type || "image/*",
452
+ position,
453
+ size: importedImage.size,
454
+ sourceTarget: targetControl.target,
455
+ },
456
+ replaceExisting,
457
+ type: "media.import",
458
+ });
304
459
  });
305
460
  });
306
461
  };
@@ -0,0 +1,166 @@
1
+ import {
2
+ getToolcraftCanvasSizeTargetDimension,
3
+ } from "../schema/runtime-targets";
4
+ import type {
5
+ ToolcraftControlConditionSchema,
6
+ ToolcraftControlSchema,
7
+ ToolcraftControlSectionSchema,
8
+ } from "../schema/types";
9
+ import type { ToolcraftState } from "../state/types";
10
+
11
+ function valuesEqual(first: unknown, second: unknown): boolean {
12
+ if (Object.is(first, second)) {
13
+ return true;
14
+ }
15
+
16
+ if (
17
+ (typeof first !== "object" || first === null) &&
18
+ (typeof second !== "object" || second === null)
19
+ ) {
20
+ return false;
21
+ }
22
+
23
+ try {
24
+ return JSON.stringify(first) === JSON.stringify(second);
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function valuesInclude(value: unknown, options: readonly unknown[]): boolean {
31
+ return options.some((option) => valuesEqual(value, option));
32
+ }
33
+
34
+ function readComparableNumber(value: unknown): number | null {
35
+ if (typeof value === "number" && Number.isFinite(value)) {
36
+ return value;
37
+ }
38
+
39
+ if (typeof value === "string") {
40
+ const numberValue = Number(value.trim());
41
+
42
+ return Number.isFinite(numberValue) ? numberValue : null;
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ function compareConditionNumbers(
49
+ value: unknown,
50
+ expected: number | undefined,
51
+ comparator: (value: number, expected: number) => boolean,
52
+ ): boolean | null {
53
+ if (typeof expected !== "number" || !Number.isFinite(expected)) {
54
+ return null;
55
+ }
56
+
57
+ const numberValue = readComparableNumber(value);
58
+
59
+ return numberValue === null ? false : comparator(numberValue, expected);
60
+ }
61
+
62
+ function getControlDefaultValueByTarget(
63
+ sections: readonly ToolcraftControlSectionSchema[],
64
+ target: string,
65
+ ): unknown {
66
+ for (const section of sections) {
67
+ for (const control of Object.values(section.controls)) {
68
+ if (control.target === target) {
69
+ return control.defaultValue;
70
+ }
71
+ }
72
+ }
73
+
74
+ return undefined;
75
+ }
76
+
77
+ export function getToolcraftTargetValue(
78
+ state: ToolcraftState,
79
+ target: string,
80
+ ): unknown {
81
+ const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(target);
82
+
83
+ return canvasSizeDimension
84
+ ? state.canvas.size[canvasSizeDimension]
85
+ : (state.values[target] ??
86
+ getControlDefaultValueByTarget(
87
+ state.schema.panels.controls?.sections ?? [],
88
+ target,
89
+ ));
90
+ }
91
+
92
+ export function toolcraftConditionMatches(
93
+ state: ToolcraftState,
94
+ condition: ToolcraftControlConditionSchema,
95
+ ): boolean {
96
+ const value = getToolcraftTargetValue(state, condition.target);
97
+ const matches: boolean[] = [];
98
+
99
+ if ("equals" in condition) {
100
+ matches.push(valuesEqual(value, condition.equals));
101
+ }
102
+
103
+ if ("notEquals" in condition) {
104
+ matches.push(!valuesEqual(value, condition.notEquals));
105
+ }
106
+
107
+ if (Array.isArray(condition.oneOf)) {
108
+ matches.push(valuesInclude(value, condition.oneOf));
109
+ }
110
+
111
+ if (Array.isArray(condition.notOneOf)) {
112
+ matches.push(!valuesInclude(value, condition.notOneOf));
113
+ }
114
+
115
+ const greaterThan = compareConditionNumbers(
116
+ value,
117
+ condition.greaterThan,
118
+ (numberValue, expected) => numberValue > expected,
119
+ );
120
+ const greaterThanOrEqual = compareConditionNumbers(
121
+ value,
122
+ condition.greaterThanOrEqual,
123
+ (numberValue, expected) => numberValue >= expected,
124
+ );
125
+ const lessThan = compareConditionNumbers(
126
+ value,
127
+ condition.lessThan,
128
+ (numberValue, expected) => numberValue < expected,
129
+ );
130
+ const lessThanOrEqual = compareConditionNumbers(
131
+ value,
132
+ condition.lessThanOrEqual,
133
+ (numberValue, expected) => numberValue <= expected,
134
+ );
135
+
136
+ for (const match of [
137
+ greaterThan,
138
+ greaterThanOrEqual,
139
+ lessThan,
140
+ lessThanOrEqual,
141
+ ]) {
142
+ if (match !== null) {
143
+ matches.push(match);
144
+ }
145
+ }
146
+
147
+ return matches.length > 0 && matches.every(Boolean);
148
+ }
149
+
150
+ export function isToolcraftSectionVisible(
151
+ state: ToolcraftState,
152
+ section: ToolcraftControlSectionSchema,
153
+ ): boolean {
154
+ return section.visibleWhen
155
+ ? toolcraftConditionMatches(state, section.visibleWhen)
156
+ : true;
157
+ }
158
+
159
+ export function isToolcraftControlVisible(
160
+ state: ToolcraftState,
161
+ control: ToolcraftControlSchema,
162
+ ): boolean {
163
+ return control.visibleWhen
164
+ ? toolcraftConditionMatches(state, control.visibleWhen)
165
+ : true;
166
+ }
@@ -0,0 +1,176 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import * as React from "react";
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4
+
5
+ import { defineToolcraft } from "../schema/define-toolcraft";
6
+ import type { FileDropPreview } from "@repo/ui";
7
+ import { ControlsPanel } from "./controls-panel";
8
+ import { ToolcraftRoot } from "./toolcraft-root";
9
+ import { useToolcraft } from "./use-toolcraft";
10
+
11
+ vi.mock("@repo/ui", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("@repo/ui")>();
13
+
14
+ return {
15
+ ...actual,
16
+ FileDrop: ({
17
+ assetKind = "image",
18
+ onPreviewReorder,
19
+ previews = [],
20
+ }: {
21
+ assetKind?: "file" | "image";
22
+ onPreviewReorder?: (previews: FileDropPreview[]) => void;
23
+ previews?: readonly FileDropPreview[];
24
+ }) => (
25
+ <button
26
+ onClick={() => {
27
+ onPreviewReorder?.([...previews].reverse());
28
+ }}
29
+ type="button"
30
+ >
31
+ Reorder {assetKind}
32
+ </button>
33
+ ),
34
+ };
35
+ });
36
+
37
+ beforeAll(() => {
38
+ Object.defineProperty(window, "matchMedia", {
39
+ value: (query: string) => ({
40
+ addEventListener: () => undefined,
41
+ addListener: () => undefined,
42
+ dispatchEvent: () => false,
43
+ matches: query === "(prefers-reduced-motion: reduce)",
44
+ media: query,
45
+ onchange: null,
46
+ removeEventListener: () => undefined,
47
+ removeListener: () => undefined,
48
+ }),
49
+ writable: true,
50
+ });
51
+ });
52
+
53
+ afterEach(() => {
54
+ cleanup();
55
+ });
56
+
57
+ function StateProbe() {
58
+ const { state } = useToolcraft();
59
+
60
+ return (
61
+ <span data-testid="media-ids">
62
+ {state.mediaAssets.map((asset) => asset.id).join(",")}
63
+ </span>
64
+ );
65
+ }
66
+
67
+ describe("ControlsPanel fileDrop reorder integration", () => {
68
+ it("commits image and file preview sorting back to runtime media order separately", async () => {
69
+ const schema = defineToolcraft({
70
+ canvas: { enabled: true, upload: true },
71
+ panels: {
72
+ controls: {
73
+ sections: [
74
+ {
75
+ controls: {
76
+ images: {
77
+ label: "Images",
78
+ multiple: true,
79
+ target: "input.images",
80
+ type: "fileDrop",
81
+ },
82
+ files: {
83
+ assetKind: "file",
84
+ label: "Files",
85
+ multiple: true,
86
+ target: "input.files",
87
+ type: "fileDrop",
88
+ },
89
+ },
90
+ title: "Sources",
91
+ },
92
+ ],
93
+ title: "Controls",
94
+ },
95
+ },
96
+ });
97
+
98
+ render(
99
+ <ToolcraftRoot
100
+ initialState={{
101
+ layers: [
102
+ { id: "layer-img-1", kind: "layer", name: "Image 1", visible: true },
103
+ { id: "layer-file-1", kind: "layer", name: "File 1", visible: true },
104
+ { id: "layer-img-2", kind: "layer", name: "Image 2", visible: true },
105
+ { id: "layer-file-2", kind: "layer", name: "File 2", visible: true },
106
+ ],
107
+ mediaAssets: [
108
+ {
109
+ assetKind: "image",
110
+ dataUrl: "data:image/png;base64,aW1nMQ==",
111
+ fileName: "one.png",
112
+ id: "img-1",
113
+ layerId: "layer-img-1",
114
+ mimeType: "image/png",
115
+ position: { x: 0, y: 0 },
116
+ size: { height: 64, unit: "px", width: 64 },
117
+ sourceTarget: "input.images",
118
+ },
119
+ {
120
+ assetKind: "file",
121
+ dataUrl: "data:text/plain;base64,ZmlsZTE=",
122
+ fileName: "one.txt",
123
+ id: "file-1",
124
+ layerId: "layer-file-1",
125
+ mimeType: "text/plain",
126
+ position: { x: 0, y: 0 },
127
+ sourceTarget: "input.files",
128
+ },
129
+ {
130
+ assetKind: "image",
131
+ dataUrl: "data:image/png;base64,aW1nMg==",
132
+ fileName: "two.png",
133
+ id: "img-2",
134
+ layerId: "layer-img-2",
135
+ mimeType: "image/png",
136
+ position: { x: 0, y: 0 },
137
+ size: { height: 64, unit: "px", width: 64 },
138
+ sourceTarget: "input.images",
139
+ },
140
+ {
141
+ assetKind: "file",
142
+ dataUrl: "data:application/json;base64,e30=",
143
+ fileName: "two.json",
144
+ id: "file-2",
145
+ layerId: "layer-file-2",
146
+ mimeType: "application/json",
147
+ position: { x: 0, y: 0 },
148
+ sourceTarget: "input.files",
149
+ },
150
+ ],
151
+ selectedLayerId: "layer-file-2",
152
+ }}
153
+ schema={schema}
154
+ >
155
+ <ControlsPanel framed={false} />
156
+ <StateProbe />
157
+ </ToolcraftRoot>,
158
+ );
159
+
160
+ expect(screen.getByTestId("media-ids").textContent).toBe(
161
+ "img-1,file-1,img-2,file-2",
162
+ );
163
+
164
+ fireEvent.click(screen.getByRole("button", { name: "Reorder image" }));
165
+
166
+ expect(screen.getByTestId("media-ids").textContent).toBe(
167
+ "img-2,file-1,img-1,file-2",
168
+ );
169
+
170
+ fireEvent.click(screen.getByRole("button", { name: "Reorder file" }));
171
+
172
+ expect(screen.getByTestId("media-ids").textContent).toBe(
173
+ "img-2,file-2,img-1,file-1",
174
+ );
175
+ });
176
+ });