@pixel-point/toolcraft 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/generate.test.mjs +1 -1
- package/templates/runtime/contracts/component-contracts.test.ts +123 -15
- package/templates/runtime/contracts/component-contracts.ts +93 -19
- package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
- package/templates/runtime/contracts/decision-contracts.ts +2 -2
- package/templates/runtime/export/export.test.ts +31 -0
- package/templates/runtime/export/export.ts +41 -0
- package/templates/runtime/react/canvas-shell.test.tsx +77 -1
- package/templates/runtime/react/canvas-shell.tsx +178 -23
- package/templates/runtime/react/control-conditions.ts +166 -0
- package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
- package/templates/runtime/react/controls-panel.test.tsx +590 -9
- package/templates/runtime/react/controls-panel.tsx +472 -10
- package/templates/runtime/react/media-file.ts +19 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
- package/templates/runtime/schema/define-toolcraft.ts +11 -3
- package/templates/runtime/schema/types.ts +27 -0
- package/templates/runtime/state/reducer.test.ts +357 -0
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/types.ts +12 -2
- package/templates/runtime/testing/performance.test.ts +1282 -48
- package/templates/runtime/testing/performance.ts +676 -39
- package/templates/starter/AGENTS.md +13 -12
- package/templates/starter/docs/toolcraft/README.md +4 -3
- package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
- package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
- package/templates/starter/docs/toolcraft/component-rules.md +20 -7
- package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
- package/templates/starter/docs/toolcraft/performance.md +51 -13
- package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
- package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
- package/templates/starter/docs/toolcraft/workflow.md +7 -10
- package/templates/starter/e2e/app-performance.spec.ts +136 -3
- package/templates/starter/e2e/performance-helpers.ts +197 -0
- package/templates/starter/package.json +4 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
- package/templates/starter/src/app/starter-acceptance.ts +269 -12
- package/templates/starter/src/app/starter-performance.test.ts +67 -6
- package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
- package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
- package/templates/ui/components/controls/collection-actions/index.ts +4 -0
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
- package/templates/ui/components/controls/file-drop/index.ts +1 -1
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
- package/templates/ui/components/controls/index.ts +9 -0
- package/templates/ui/components/panel/panel-section.tsx +53 -1
- package/templates/ui/index.ts +1 -0
|
@@ -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
|
|
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,
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
379
|
+
const uploadedFiles = Array.from(event.dataTransfer?.files ?? []);
|
|
283
380
|
|
|
284
|
-
if (
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
+
}
|