@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.
Files changed (48) 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 +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -177,7 +177,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
177
177
  currentConstraint:
178
178
  "Product-output apps expose final output delivery through sticky footer panelActions.",
179
179
  desiredBehavior:
180
- 'Static products include Export PNG plus an "Image Export" section for format and 2K/4K/8K resolution; animated products include Export Video and Export PNG plus "Video Export" settings. Copy can be secondary, but it does not replace export. Product apps expose a required "Background" section directly before export settings, with a Switch labeled "Include" and a background color control with label false in one equal-width row. Standard export helpers own runtime PNG transparency and selected image dimensions or retina fallback, while live preview, workspace canvas backing, and video keep the background.',
180
+ 'Static products include Export PNG plus an "Image Export" section for format and 2K/4K/8K resolution; animated products include Export Video and Export PNG plus "Video Export" settings. Copy can be secondary, but it does not replace export. Product apps expose a required "Background" section directly before export settings, with a Switch labeled "Include" and a background color control with label false in one equal-width row. Standard helpers own runtime PNG transparency, live preview product-background visibility, and selected image dimensions or retina fallback, while Toolcraft canvas backing and video keep the background.',
181
181
  enforcement: ["acceptance-validator", "performance-validator", "browser-helper", "starter-agents"],
182
182
  id: "output-export-required",
183
183
  level: "invariant",
@@ -237,7 +237,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
237
237
  currentConstraint:
238
238
  "Performance coverage currently asks every visible non-action control for a performance scenario.",
239
239
  desiredBehavior:
240
- "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required when the first working version of an app exists, when renderer/canvas/animation/export/timeline/layers change, when canvas.renderScale or the Resolution scale retina slider is added/enabled, after fixing a bug that previously broke functionality, after any performance optimization, and whenever the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance tests read budgets from typed performance config and run sequentially for stable measurements.",
240
+ "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required only when the first working version of an app exists, or whenever the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance. Renderer, canvas, animation, export, timeline, layers, canvas.renderScale, bug fixes, and performance-sensitive controls need targeted functional/browser checks first and targeted performance scenarios only for touched workload/viewport/export paths. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance tests read budgets from typed performance config and run sequentially for stable measurements.",
241
241
  enforcement: ["performance-validator", "browser-helper", "starter-agents"],
242
242
  id: "performance-coverage-levels",
243
243
  level: "invariant",
@@ -8,6 +8,7 @@ import {
8
8
  getToolcraftRetinaExportPixelRatio,
9
9
  getToolcraftRetinaExportSize,
10
10
  shouldIncludeToolcraftExportBackground,
11
+ shouldIncludeToolcraftPreviewBackground,
11
12
  } from "./export";
12
13
 
13
14
  function createState(schema = defineToolcraft({ canvas: { enabled: true }, panels: {} })): ToolcraftState {
@@ -89,6 +90,36 @@ describe("Toolcraft export helpers", () => {
89
90
  expect(shouldIncludeToolcraftExportBackground({ format: "video", schema })).toBe(true);
90
91
  });
91
92
 
93
+ it("uses include-background runtime state for live preview background", () => {
94
+ const state = createState();
95
+
96
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(true);
97
+
98
+ state.values["export.includeBackground"] = false;
99
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(false);
100
+
101
+ state.values["export.includeBackground"] = true;
102
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(true);
103
+ });
104
+
105
+ it("keeps video export background independent from preview include state", () => {
106
+ const schema = defineToolcraft({
107
+ canvas: { enabled: true },
108
+ export: {
109
+ png: {
110
+ background: "transparent",
111
+ },
112
+ },
113
+ panels: {},
114
+ });
115
+ const state = createState(schema);
116
+
117
+ state.values["export.includeBackground"] = false;
118
+
119
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(false);
120
+ expect(shouldIncludeToolcraftExportBackground({ format: "video", schema })).toBe(true);
121
+ });
122
+
92
123
  it("uses at least 2x pixel ratio for retina export", () => {
93
124
  expect(getToolcraftRetinaExportPixelRatio(1)).toBe(2);
94
125
  expect(getToolcraftRetinaExportPixelRatio(1.5)).toBe(2);
@@ -16,6 +16,11 @@ export type ToolcraftExportBackgroundOptions = {
16
16
  schema: ResolvedToolcraftAppSchema;
17
17
  };
18
18
 
19
+ export type ToolcraftPreviewBackgroundOptions = {
20
+ includeBackgroundTarget?: string;
21
+ state: ToolcraftState;
22
+ };
23
+
19
24
  export type ToolcraftExportSizeOptions = {
20
25
  devicePixelRatio?: number;
21
26
  state: ToolcraftState;
@@ -129,6 +134,42 @@ export function shouldIncludeToolcraftExportBackground({
129
134
  return schema.export.png.background !== "transparent";
130
135
  }
131
136
 
137
+ export function shouldIncludeToolcraftPreviewBackground({
138
+ includeBackgroundTarget = "export.includeBackground",
139
+ state,
140
+ }: ToolcraftPreviewBackgroundOptions): boolean {
141
+ const includeBackgroundValue = state.values[includeBackgroundTarget];
142
+
143
+ if (typeof includeBackgroundValue === "boolean") {
144
+ return includeBackgroundValue;
145
+ }
146
+
147
+ if (typeof includeBackgroundValue === "string") {
148
+ const normalizedValue = includeBackgroundValue.trim().toLowerCase();
149
+
150
+ if (
151
+ normalizedValue === "false" ||
152
+ normalizedValue === "off" ||
153
+ normalizedValue === "no" ||
154
+ normalizedValue === "transparent" ||
155
+ normalizedValue === "exclude"
156
+ ) {
157
+ return false;
158
+ }
159
+
160
+ if (
161
+ normalizedValue === "true" ||
162
+ normalizedValue === "on" ||
163
+ normalizedValue === "yes" ||
164
+ normalizedValue === "include"
165
+ ) {
166
+ return true;
167
+ }
168
+ }
169
+
170
+ return true;
171
+ }
172
+
132
173
  export function createToolcraftPngExportCanvas({
133
174
  background = "#000000",
134
175
  canvasFactory = () => document.createElement("canvas"),
@@ -43,7 +43,13 @@ function CanvasStateProbe() {
43
43
  <span data-testid="canvas-zoom">{state.canvas.zoom}</span>
44
44
  <span data-testid="media-count">{state.mediaAssets.length}</span>
45
45
  <span data-testid="media-size">
46
- {state.mediaAssets[0]?.size.width ?? 0},{state.mediaAssets[0]?.size.height ?? 0}
46
+ {state.mediaAssets[0]?.size?.width ?? 0},{state.mediaAssets[0]?.size?.height ?? 0}
47
+ </span>
48
+ <span data-testid="media-targets">
49
+ {state.mediaAssets.map((asset) => asset.sourceTarget ?? "none").join(",")}
50
+ </span>
51
+ <span data-testid="media-kinds">
52
+ {state.mediaAssets.map((asset) => asset.assetKind ?? "image").join(",")}
47
53
  </span>
48
54
  </>
49
55
  );
@@ -376,6 +382,76 @@ describe("CanvasShell", () => {
376
382
  expect(screen.getByRole("button", { name: "Select material.png" })).toBeTruthy();
377
383
  });
378
384
 
385
+ it("routes canvas drops to image and file upload targets by asset kind", async () => {
386
+ const schema = defineToolcraft({
387
+ canvas: {
388
+ enabled: true,
389
+ size: { width: 300, height: 200, unit: "px" },
390
+ upload: true,
391
+ },
392
+ panels: {
393
+ controls: {
394
+ sections: [
395
+ {
396
+ controls: {
397
+ files: {
398
+ assetKind: "file",
399
+ label: "Files",
400
+ multiple: true,
401
+ target: "source.files",
402
+ type: "fileDrop",
403
+ },
404
+ image: {
405
+ label: "Image",
406
+ target: "source.image",
407
+ type: "fileDrop",
408
+ },
409
+ },
410
+ title: "Sources",
411
+ },
412
+ ],
413
+ title: "Controls",
414
+ },
415
+ },
416
+ });
417
+
418
+ render(
419
+ <ToolcraftRoot schema={schema}>
420
+ <div style={{ height: 640, position: "relative", width: 640 }}>
421
+ <CanvasShell />
422
+ <CanvasStateProbe />
423
+ </div>
424
+ </ToolcraftRoot>,
425
+ );
426
+
427
+ const canvas = screen.getByRole("application", { name: "Canvas viewport" });
428
+ const imageFile = new File(["image"], "material.png", { type: "image/png" });
429
+ const textFile = new File(["notes"], "notes.txt", { type: "text/plain" });
430
+
431
+ fireEvent.drop(canvas, {
432
+ clientX: 0,
433
+ clientY: 0,
434
+ dataTransfer: {
435
+ files: [imageFile, textFile],
436
+ },
437
+ });
438
+
439
+ await waitFor(() => {
440
+ expect(screen.getByTestId("media-count").textContent).toBe("2");
441
+ });
442
+
443
+ expect(screen.getByTestId("media-targets").textContent?.split(",").sort()).toEqual([
444
+ "source.files",
445
+ "source.image",
446
+ ]);
447
+ expect(screen.getByTestId("media-kinds").textContent?.split(",").sort()).toEqual([
448
+ "file",
449
+ "image",
450
+ ]);
451
+ expect(screen.getByRole("button", { name: "Select material.png" })).toBeTruthy();
452
+ expect(screen.queryByRole("button", { name: "Select notes.txt" })).toBeNull();
453
+ });
454
+
379
455
  it("hides media when a parent layer group is hidden", () => {
380
456
  render(
381
457
  <ToolcraftRoot
@@ -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
+ }