@pixel-point/toolcraft 0.0.8 → 0.0.11
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/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +42 -5
- package/src/generate.test.mjs +40 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +251 -47
- package/templates/runtime/contracts/component-contracts.ts +130 -57
- package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
- package/templates/runtime/contracts/decision-contracts.ts +22 -9
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +916 -108
- package/templates/runtime/react/controls-panel.tsx +136 -30
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
- package/templates/runtime/schema/define-toolcraft.ts +140 -246
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +44 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +810 -21
- package/templates/runtime/testing/performance.ts +823 -53
- package/templates/starter/AGENTS.md +24 -18
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
- package/templates/starter/docs/toolcraft/component-rules.md +75 -42
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +37 -11
- package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
- package/templates/starter/scripts/toolcraft-port.mjs +280 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
- package/templates/starter/src/app/starter-acceptance.ts +1453 -97
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +4 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +8 -25
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
- package/templates/ui/components/panel/panel-section.tsx +29 -5
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
5
|
import { cn } from "../../../lib/utils";
|
|
6
|
+
import {
|
|
7
|
+
createControlHistoryGroupId,
|
|
8
|
+
type ControlChangeMeta,
|
|
9
|
+
} from "../control-types";
|
|
6
10
|
import {
|
|
7
11
|
PALETTE_SHADE_STEPS,
|
|
8
12
|
STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS,
|
|
@@ -21,7 +25,7 @@ export {
|
|
|
21
25
|
};
|
|
22
26
|
export type { PaletteColorFamily, PaletteControlValue, PaletteShadeStep };
|
|
23
27
|
|
|
24
|
-
export type PaletteControlChangeMeta = {
|
|
28
|
+
export type PaletteControlChangeMeta = ControlChangeMeta & {
|
|
25
29
|
stage: "live" | "commit";
|
|
26
30
|
hex: string;
|
|
27
31
|
};
|
|
@@ -91,6 +95,7 @@ export function PaletteControl({
|
|
|
91
95
|
const pendingPersistRef = React.useRef<PaletteControlValue | null>(null);
|
|
92
96
|
const clickCommitTimeoutRef = React.useRef<number | null>(null);
|
|
93
97
|
const persistTimeoutRef = React.useRef<number | null>(null);
|
|
98
|
+
const liveHistoryGroupRef = React.useRef<string | null>(null);
|
|
94
99
|
const isInteractingRef = React.useRef(false);
|
|
95
100
|
const shadeTrackRef = React.useRef<HTMLDivElement | null>(null);
|
|
96
101
|
const onValueChangeRef = React.useRef(onValueChange);
|
|
@@ -148,12 +153,34 @@ export function PaletteControl({
|
|
|
148
153
|
persistTimeoutRef.current = null;
|
|
149
154
|
}, []);
|
|
150
155
|
|
|
156
|
+
const getLiveHistoryMeta = React.useCallback((): ControlChangeMeta => {
|
|
157
|
+
liveHistoryGroupRef.current ??= createControlHistoryGroupId("palette");
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
history: "merge",
|
|
161
|
+
historyGroup: liveHistoryGroupRef.current,
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const finishLiveHistoryGroup = React.useCallback(() => {
|
|
166
|
+
liveHistoryGroupRef.current = null;
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
151
169
|
const emitChange = React.useCallback(
|
|
152
170
|
(nextValue: PaletteControlValue, stage: PaletteControlChangeMeta["stage"]) => {
|
|
153
171
|
const hex = getPaletteHex(nextValue);
|
|
154
|
-
|
|
172
|
+
const historyMeta =
|
|
173
|
+
stage === "live" || liveHistoryGroupRef.current
|
|
174
|
+
? getLiveHistoryMeta()
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
onValueChangeRef.current?.(nextValue, {
|
|
178
|
+
...historyMeta,
|
|
179
|
+
stage,
|
|
180
|
+
hex,
|
|
181
|
+
});
|
|
155
182
|
},
|
|
156
|
-
[],
|
|
183
|
+
[getLiveHistoryMeta],
|
|
157
184
|
);
|
|
158
185
|
|
|
159
186
|
const syncOptimisticValue = React.useCallback((nextValue: PaletteControlValue) => {
|
|
@@ -174,6 +201,7 @@ export function PaletteControl({
|
|
|
174
201
|
if (options?.immediate) {
|
|
175
202
|
pendingPersistRef.current = null;
|
|
176
203
|
onCommitRef.current?.(pendingPersist, getPaletteHex(pendingPersist));
|
|
204
|
+
finishLiveHistoryGroup();
|
|
177
205
|
setInteractionState(false);
|
|
178
206
|
return true;
|
|
179
207
|
}
|
|
@@ -184,17 +212,19 @@ export function PaletteControl({
|
|
|
184
212
|
pendingPersistRef.current = null;
|
|
185
213
|
|
|
186
214
|
if (!nextPersist) {
|
|
215
|
+
finishLiveHistoryGroup();
|
|
187
216
|
setInteractionState(false);
|
|
188
217
|
return;
|
|
189
218
|
}
|
|
190
219
|
|
|
191
220
|
onCommitRef.current?.(nextPersist, getPaletteHex(nextPersist));
|
|
221
|
+
finishLiveHistoryGroup();
|
|
192
222
|
setInteractionState(false);
|
|
193
223
|
}, PERSIST_SETTLE_MS);
|
|
194
224
|
|
|
195
225
|
return true;
|
|
196
226
|
},
|
|
197
|
-
[clearPersistTimeout, setInteractionState],
|
|
227
|
+
[clearPersistTimeout, finishLiveHistoryGroup, setInteractionState],
|
|
198
228
|
);
|
|
199
229
|
|
|
200
230
|
const flushPendingCommit = React.useCallback((options?: { persistImmediately?: boolean }) => {
|
|
@@ -459,9 +459,14 @@ export function useHueHandlers({
|
|
|
459
459
|
|
|
460
460
|
const handleHuePreviewChange = useCallback(
|
|
461
461
|
(nextHue: number) => {
|
|
462
|
-
applyOptimisticColor({
|
|
462
|
+
const nextHex = applyOptimisticColor({
|
|
463
|
+
h: nextHue,
|
|
464
|
+
s: latestHsvRef.current.s,
|
|
465
|
+
v: latestHsvRef.current.v,
|
|
466
|
+
});
|
|
467
|
+
emitChange(nextHex);
|
|
463
468
|
},
|
|
464
|
-
[applyOptimisticColor, latestHsvRef],
|
|
469
|
+
[applyOptimisticColor, emitChange, latestHsvRef],
|
|
465
470
|
);
|
|
466
471
|
|
|
467
472
|
const handleHueCommit = useCallback(
|
|
@@ -170,9 +170,9 @@ function useRgbBlueHandlers({
|
|
|
170
170
|
|
|
171
171
|
const handleSliderPreviewChange = useCallback(
|
|
172
172
|
(nextValue: number) => {
|
|
173
|
-
applyBlueChannel(nextValue);
|
|
173
|
+
emitChange(applyBlueChannel(nextValue));
|
|
174
174
|
},
|
|
175
|
-
[applyBlueChannel],
|
|
175
|
+
[applyBlueChannel, emitChange],
|
|
176
176
|
);
|
|
177
177
|
|
|
178
178
|
const handleSliderCommit = useCallback(
|
|
@@ -19,13 +19,33 @@ import {
|
|
|
19
19
|
useSortable,
|
|
20
20
|
} from "@dnd-kit/sortable";
|
|
21
21
|
import { CSS } from "@dnd-kit/utilities";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
CloudArrowUpIcon,
|
|
24
|
+
PaperclipIcon,
|
|
25
|
+
XIcon,
|
|
26
|
+
} from "@phosphor-icons/react";
|
|
23
27
|
|
|
24
28
|
import { cn } from "../../../lib/utils";
|
|
29
|
+
import {
|
|
30
|
+
ActionsControl,
|
|
31
|
+
type ActionControlOption,
|
|
32
|
+
} from "../actions/actions-control";
|
|
25
33
|
import { Button, Field } from "../../primitives";
|
|
26
34
|
|
|
27
35
|
export type FileDropAssetKind = "file" | "image";
|
|
28
36
|
|
|
37
|
+
export type FileDropImageTransform = {
|
|
38
|
+
flipHorizontal?: boolean;
|
|
39
|
+
flipVertical?: boolean;
|
|
40
|
+
rotationDeg?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type FileDropImageTransformOperation =
|
|
44
|
+
| "flip-horizontal"
|
|
45
|
+
| "flip-vertical"
|
|
46
|
+
| "rotate-left"
|
|
47
|
+
| "rotate-right";
|
|
48
|
+
|
|
29
49
|
export type FileDropPreview = {
|
|
30
50
|
alt?: string;
|
|
31
51
|
assetKind?: FileDropAssetKind;
|
|
@@ -36,6 +56,7 @@ export type FileDropPreview = {
|
|
|
36
56
|
width: number;
|
|
37
57
|
};
|
|
38
58
|
src: string;
|
|
59
|
+
transform?: FileDropImageTransform;
|
|
39
60
|
};
|
|
40
61
|
|
|
41
62
|
export type FileDropControlProps = {
|
|
@@ -47,10 +68,16 @@ export type FileDropControlProps = {
|
|
|
47
68
|
onFilesSelect?: (files: File[]) => void;
|
|
48
69
|
onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
|
|
49
70
|
onPreviewReorder?: (orderedPreviews: FileDropPreview[]) => void;
|
|
71
|
+
onPreviewTransform?: (
|
|
72
|
+
preview: FileDropPreview,
|
|
73
|
+
operation: FileDropImageTransformOperation,
|
|
74
|
+
) => void;
|
|
50
75
|
preview?: FileDropPreview;
|
|
51
76
|
previews?: readonly FileDropPreview[];
|
|
52
77
|
};
|
|
53
78
|
|
|
79
|
+
const singleImagePreviewMaxHeight = 196;
|
|
80
|
+
|
|
54
81
|
function isDragLeavingCurrentTarget(event: React.DragEvent<HTMLElement>): boolean {
|
|
55
82
|
const nextTarget = event.relatedTarget;
|
|
56
83
|
|
|
@@ -88,15 +115,76 @@ function getFileInputAccept(accept: string): string {
|
|
|
88
115
|
.join(",");
|
|
89
116
|
}
|
|
90
117
|
|
|
91
|
-
function
|
|
118
|
+
function normalizeImageRotation(rotationDeg: number | undefined): 0 | 90 | 180 | 270 {
|
|
119
|
+
const normalized = ((Math.round((rotationDeg ?? 0) / 90) * 90) % 360 + 360) % 360;
|
|
120
|
+
|
|
121
|
+
return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getImageTransformStyle(
|
|
125
|
+
transform: FileDropImageTransform | undefined,
|
|
126
|
+
options: { center?: boolean; includeRotation?: boolean } = {},
|
|
127
|
+
): React.CSSProperties {
|
|
128
|
+
const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
|
|
129
|
+
const scaleX = transform?.flipHorizontal ? -1 : 1;
|
|
130
|
+
const scaleY = transform?.flipVertical ? -1 : 1;
|
|
131
|
+
const transformSteps: string[] = [];
|
|
132
|
+
|
|
133
|
+
if (options.center) {
|
|
134
|
+
transformSteps.push("translate(-50%, -50%)");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (options.includeRotation !== false && rotationDeg !== 0) {
|
|
138
|
+
transformSteps.push(`rotate(${rotationDeg}deg)`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
142
|
+
transformSteps.push(`scale(${scaleX}, ${scaleY})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (transformSteps.length === 0) {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
transform: transformSteps.join(" "),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getPreviewFrameStyle(
|
|
155
|
+
size: FileDropPreview["size"],
|
|
156
|
+
): React.CSSProperties {
|
|
92
157
|
if (!size || size.height <= 0 || size.width <= 0) {
|
|
93
158
|
return { width: "100%" };
|
|
94
159
|
}
|
|
95
160
|
|
|
96
|
-
|
|
161
|
+
return {
|
|
162
|
+
aspectRatio: `${size.width} / ${size.height}`,
|
|
163
|
+
maxHeight: `${singleImagePreviewMaxHeight}px`,
|
|
164
|
+
width: "100%",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isPreviewQuarterTurn(transform?: FileDropImageTransform): boolean {
|
|
169
|
+
const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
|
|
170
|
+
|
|
171
|
+
return rotationDeg === 90 || rotationDeg === 270;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getPreviewImageStyle(
|
|
175
|
+
size: FileDropPreview["size"],
|
|
176
|
+
transform?: FileDropImageTransform,
|
|
177
|
+
): React.CSSProperties {
|
|
178
|
+
const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
|
|
179
|
+
const rotatedQuarterTurn = rotationDeg === 90 || rotationDeg === 270;
|
|
180
|
+
const rotatedWidth =
|
|
181
|
+
size && size.height > 0 && size.width > 0 && rotatedQuarterTurn
|
|
182
|
+
? `${(Math.min(size.width / size.height, size.height / size.width) * 100).toFixed(4)}%`
|
|
183
|
+
: "100%";
|
|
97
184
|
|
|
98
185
|
return {
|
|
99
|
-
|
|
186
|
+
...getImageTransformStyle(transform, { center: true }),
|
|
187
|
+
...(rotatedQuarterTurn ? { width: rotatedWidth } : {}),
|
|
100
188
|
};
|
|
101
189
|
}
|
|
102
190
|
|
|
@@ -119,12 +207,30 @@ function FileDropPlusGlyph({ className }: { className?: string }): React.JSX.Ele
|
|
|
119
207
|
);
|
|
120
208
|
}
|
|
121
209
|
|
|
210
|
+
const imageTransformActions = [
|
|
211
|
+
{ ariaLabel: "90° Right", icon: "rotate-cw", label: "90°", value: "rotate-right" },
|
|
212
|
+
{
|
|
213
|
+
ariaLabel: "Flip horizontal",
|
|
214
|
+
icon: "flip-horizontal",
|
|
215
|
+
label: "Flip H",
|
|
216
|
+
value: "flip-horizontal",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
ariaLabel: "Flip vertical",
|
|
220
|
+
icon: "flip-vertical",
|
|
221
|
+
label: "Flip V",
|
|
222
|
+
value: "flip-vertical",
|
|
223
|
+
},
|
|
224
|
+
] satisfies readonly ActionControlOption[];
|
|
225
|
+
|
|
122
226
|
type SortablePreviewTileProps = {
|
|
123
227
|
index: number;
|
|
124
228
|
isSortable: boolean;
|
|
125
229
|
item: FileDropPreview;
|
|
126
230
|
itemKey: string;
|
|
127
231
|
onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
|
|
232
|
+
onPreviewSelect?: (preview: FileDropPreview) => void;
|
|
233
|
+
selected?: boolean;
|
|
128
234
|
};
|
|
129
235
|
|
|
130
236
|
function SortablePreviewTile({
|
|
@@ -133,6 +239,8 @@ function SortablePreviewTile({
|
|
|
133
239
|
item,
|
|
134
240
|
itemKey,
|
|
135
241
|
onPreviewRemove,
|
|
242
|
+
onPreviewSelect,
|
|
243
|
+
selected = false,
|
|
136
244
|
}: SortablePreviewTileProps): React.JSX.Element {
|
|
137
245
|
const { attributes, isDragging, listeners, setNodeRef, transform, transition } =
|
|
138
246
|
useSortable({
|
|
@@ -157,14 +265,18 @@ function SortablePreviewTile({
|
|
|
157
265
|
"relative aspect-square min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)-4px)] bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] transition-[transform,box-shadow,opacity,border-color] duration-180 ease-out motion-reduce:transition-none",
|
|
158
266
|
isSortable &&
|
|
159
267
|
"cursor-grab touch-none select-none will-change-transform hover:shadow-[0_8px_18px_color-mix(in_oklab,var(--background)_30%,transparent)]",
|
|
268
|
+
selected &&
|
|
269
|
+
"ring-2 ring-[color:color-mix(in_oklab,var(--link)_72%,transparent)] ring-offset-2 ring-offset-[color:var(--background)]",
|
|
160
270
|
isDragging &&
|
|
161
271
|
"z-10 cursor-grabbing opacity-90 shadow-[0_12px_24px_color-mix(in_oklab,var(--background)_55%,transparent)]",
|
|
162
272
|
)}
|
|
163
273
|
data-file-upload-preview-key={itemKey}
|
|
164
274
|
data-preview-dragging={isDragging ? "true" : undefined}
|
|
275
|
+
data-selected={selected ? "true" : undefined}
|
|
165
276
|
data-slot="file-upload-preview-item"
|
|
166
277
|
onClick={(event) => {
|
|
167
278
|
event.stopPropagation();
|
|
279
|
+
onPreviewSelect?.(item);
|
|
168
280
|
}}
|
|
169
281
|
ref={setNodeRef}
|
|
170
282
|
style={style}
|
|
@@ -177,6 +289,7 @@ function SortablePreviewTile({
|
|
|
177
289
|
draggable={false}
|
|
178
290
|
height={item.size?.height}
|
|
179
291
|
src={item.src}
|
|
292
|
+
style={getImageTransformStyle(item.transform)}
|
|
180
293
|
width={item.size?.width}
|
|
181
294
|
/>
|
|
182
295
|
{onPreviewRemove ? (
|
|
@@ -288,11 +401,13 @@ export function FileDropControl({
|
|
|
288
401
|
onFilesSelect,
|
|
289
402
|
onPreviewRemove,
|
|
290
403
|
onPreviewReorder,
|
|
404
|
+
onPreviewTransform,
|
|
291
405
|
preview,
|
|
292
406
|
previews,
|
|
293
407
|
}: FileDropControlProps): React.JSX.Element {
|
|
294
408
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
295
409
|
const [dragOver, setDragOver] = React.useState(false);
|
|
410
|
+
const [selectedPreviewKey, setSelectedPreviewKey] = React.useState<string | null>(null);
|
|
296
411
|
const sensors = useSensors(
|
|
297
412
|
useSensor(PointerSensor, {
|
|
298
413
|
activationConstraint: {
|
|
@@ -312,6 +427,26 @@ export function FileDropControl({
|
|
|
312
427
|
key: getPreviewKey(item, index),
|
|
313
428
|
}));
|
|
314
429
|
const previewKeys = previewEntries.map((entry) => entry.key);
|
|
430
|
+
const requiresImageSelection = assetKind === "image" && multiple && previewEntries.length > 1;
|
|
431
|
+
const selectedPreviewEntry = requiresImageSelection
|
|
432
|
+
? previewEntries.find((entry) => entry.key === selectedPreviewKey)
|
|
433
|
+
: previewEntries[0];
|
|
434
|
+
const shouldRenderImageActions =
|
|
435
|
+
assetKind === "image" &&
|
|
436
|
+
hasPreview &&
|
|
437
|
+
Boolean(onPreviewTransform) &&
|
|
438
|
+
Boolean(selectedPreviewEntry) &&
|
|
439
|
+
(!requiresImageSelection || selectedPreviewKey !== null);
|
|
440
|
+
|
|
441
|
+
React.useEffect(() => {
|
|
442
|
+
if (!selectedPreviewKey) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!requiresImageSelection || !previewKeys.includes(selectedPreviewKey)) {
|
|
447
|
+
setSelectedPreviewKey(null);
|
|
448
|
+
}
|
|
449
|
+
}, [previewKeys, requiresImageSelection, selectedPreviewKey]);
|
|
315
450
|
|
|
316
451
|
function handleFiles(fileList: FileList | readonly File[] | undefined): void {
|
|
317
452
|
const files = Array.from(fileList ?? []);
|
|
@@ -371,8 +506,16 @@ export function FileDropControl({
|
|
|
371
506
|
);
|
|
372
507
|
}
|
|
373
508
|
|
|
509
|
+
function runImageTransform(operation: FileDropImageTransformOperation): void {
|
|
510
|
+
if (!selectedPreviewEntry) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
onPreviewTransform?.(selectedPreviewEntry.item, operation);
|
|
515
|
+
}
|
|
516
|
+
|
|
374
517
|
return (
|
|
375
|
-
<Field className="min-w-0 gap
|
|
518
|
+
<Field className="min-w-0" style={{ gap: "6px" }}>
|
|
376
519
|
<input
|
|
377
520
|
accept={getFileInputAccept(accept)}
|
|
378
521
|
aria-hidden="true"
|
|
@@ -497,6 +640,13 @@ export function FileDropControl({
|
|
|
497
640
|
itemKey={key}
|
|
498
641
|
key={key}
|
|
499
642
|
onPreviewRemove={onPreviewRemove}
|
|
643
|
+
onPreviewSelect={(preview) => {
|
|
644
|
+
const nextKey = getPreviewKey(preview, index);
|
|
645
|
+
setSelectedPreviewKey((currentKey) =>
|
|
646
|
+
currentKey === nextKey ? null : nextKey,
|
|
647
|
+
);
|
|
648
|
+
}}
|
|
649
|
+
selected={selectedPreviewKey === key}
|
|
500
650
|
/>
|
|
501
651
|
))}
|
|
502
652
|
</SortableContext>
|
|
@@ -517,15 +667,26 @@ export function FileDropControl({
|
|
|
517
667
|
) : hasPreview ? (
|
|
518
668
|
<>
|
|
519
669
|
{previewItems[0] ? (
|
|
520
|
-
<
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
670
|
+
<div
|
|
671
|
+
className="relative w-full max-w-full overflow-hidden rounded-[calc(var(--radius-lg)-4px)]"
|
|
672
|
+
data-slot="file-upload-preview-frame"
|
|
673
|
+
style={getPreviewFrameStyle(previewItems[0].size)}
|
|
674
|
+
>
|
|
675
|
+
<img
|
|
676
|
+
alt={previewItems[0].alt ?? ""}
|
|
677
|
+
className={cn(
|
|
678
|
+
"absolute top-1/2 left-1/2 block h-auto object-contain",
|
|
679
|
+
isPreviewQuarterTurn(previewItems[0].transform)
|
|
680
|
+
? "max-w-none"
|
|
681
|
+
: "max-h-full max-w-full",
|
|
682
|
+
)}
|
|
683
|
+
draggable={false}
|
|
684
|
+
height={previewItems[0].size?.height}
|
|
685
|
+
src={previewItems[0].src}
|
|
686
|
+
style={getPreviewImageStyle(previewItems[0].size, previewItems[0].transform)}
|
|
687
|
+
width={previewItems[0].size?.width}
|
|
688
|
+
/>
|
|
689
|
+
</div>
|
|
529
690
|
) : null}
|
|
530
691
|
{onClear ? (
|
|
531
692
|
<Button
|
|
@@ -565,6 +726,17 @@ export function FileDropControl({
|
|
|
565
726
|
</>
|
|
566
727
|
)}
|
|
567
728
|
</div>
|
|
729
|
+
{shouldRenderImageActions ? (
|
|
730
|
+
<ActionsControl
|
|
731
|
+
actions={imageTransformActions}
|
|
732
|
+
buttonColumns={3}
|
|
733
|
+
name="Image transforms"
|
|
734
|
+
onAction={(operation) =>
|
|
735
|
+
runImageTransform(operation as FileDropImageTransformOperation)
|
|
736
|
+
}
|
|
737
|
+
showLabel={false}
|
|
738
|
+
/>
|
|
739
|
+
) : null}
|
|
568
740
|
</Field>
|
|
569
741
|
);
|
|
570
742
|
}
|
|
@@ -2,4 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
export { FileDropControl } from "./file-drop-control";
|
|
4
4
|
export type { FileDropControlProps } from "./file-drop-control";
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
FileDropAssetKind,
|
|
7
|
+
FileDropImageTransform,
|
|
8
|
+
FileDropImageTransformOperation,
|
|
9
|
+
FileDropPreview,
|
|
10
|
+
} from "./file-drop-control";
|
|
@@ -52,6 +52,7 @@ export {
|
|
|
52
52
|
getSurfaceHsvColor,
|
|
53
53
|
PaletteControl,
|
|
54
54
|
PaletteControl as Palette,
|
|
55
|
+
StyleGuideColorPicker,
|
|
55
56
|
} from "./color";
|
|
56
57
|
export type {
|
|
57
58
|
ColorFormatMode,
|
|
@@ -118,6 +119,8 @@ export type {
|
|
|
118
119
|
FileDropAssetKind,
|
|
119
120
|
FileDropControlProps,
|
|
120
121
|
FileDropControlProps as FileDropProps,
|
|
122
|
+
FileDropImageTransform,
|
|
123
|
+
FileDropImageTransformOperation,
|
|
121
124
|
FileDropPreview,
|
|
122
125
|
} from "./file-drop";
|
|
123
126
|
export {
|
|
@@ -197,5 +200,6 @@ export type {
|
|
|
197
200
|
VectorControlProps as VectorProps,
|
|
198
201
|
VectorControlValue,
|
|
199
202
|
VectorControlValue as VectorValue,
|
|
203
|
+
VectorPadCoordinateMode,
|
|
200
204
|
VectorPadVariant,
|
|
201
205
|
} from "./vector";
|
|
@@ -12,6 +12,7 @@ export type RangeInputControlProps = {
|
|
|
12
12
|
end: string;
|
|
13
13
|
name: string;
|
|
14
14
|
onValueChange?: ControlValueChangeHandler<{ start: string; end: string }>;
|
|
15
|
+
showLabel?: boolean;
|
|
15
16
|
start: string;
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -88,6 +89,7 @@ export function RangeInputControl({
|
|
|
88
89
|
end,
|
|
89
90
|
name,
|
|
90
91
|
onValueChange,
|
|
92
|
+
showLabel = true,
|
|
91
93
|
start,
|
|
92
94
|
}: RangeInputControlProps): React.JSX.Element {
|
|
93
95
|
const [currentValue, setCurrentValue] = React.useState({ end, start });
|
|
@@ -142,7 +144,7 @@ export function RangeInputControl({
|
|
|
142
144
|
if (shouldUseFullWidthRangeInputs(start, end)) {
|
|
143
145
|
return (
|
|
144
146
|
<Field className="h-fit min-w-0 gap-2">
|
|
145
|
-
<ControlFieldLabel>{name}</ControlFieldLabel>
|
|
147
|
+
{showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
|
|
146
148
|
<RangeInputs
|
|
147
149
|
className="w-full"
|
|
148
150
|
end={currentValue.end}
|
|
@@ -157,10 +159,16 @@ export function RangeInputControl({
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
return (
|
|
160
|
-
<Field
|
|
161
|
-
|
|
162
|
+
<Field
|
|
163
|
+
className={cn(
|
|
164
|
+
"h-fit min-w-0",
|
|
165
|
+
showLabel ? "items-center justify-between gap-3" : undefined,
|
|
166
|
+
)}
|
|
167
|
+
orientation={showLabel ? "horizontal" : "vertical"}
|
|
168
|
+
>
|
|
169
|
+
{showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
|
|
162
170
|
<RangeInputs
|
|
163
|
-
className="w-1/2 shrink-0"
|
|
171
|
+
className={showLabel ? "w-1/2 shrink-0" : "w-full"}
|
|
164
172
|
end={currentValue.end}
|
|
165
173
|
name={name}
|
|
166
174
|
onCancel={cancelDraft}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { applySliderValueLabelUnit } from "../slider/slider-value";
|
|
2
2
|
|
|
3
|
+
const rangeSeparatorPattern = /(\d)\s*[-‐‑‒–—−]\s*(?=\d)/g;
|
|
4
|
+
|
|
3
5
|
export function formatRangeSliderValue(
|
|
4
6
|
value: readonly number[],
|
|
5
7
|
unit?: string,
|
|
@@ -44,7 +46,7 @@ export function parseRangeSliderDraft(
|
|
|
44
46
|
step: number;
|
|
45
47
|
},
|
|
46
48
|
): [number, number] | null {
|
|
47
|
-
const normalizedDraftValue = draftValue.replace(
|
|
49
|
+
const normalizedDraftValue = draftValue.replace(rangeSeparatorPattern, "$1 ");
|
|
48
50
|
const values = Array.from(
|
|
49
51
|
normalizedDraftValue.matchAll(/-?\d+(?:[.,]\d+)?/g),
|
|
50
52
|
([match]) => Number(match.replace(",", ".")),
|
|
@@ -21,16 +21,15 @@ export type SelectControlInput = {
|
|
|
21
21
|
name: string;
|
|
22
22
|
onValueChange?: (value: string) => void;
|
|
23
23
|
options: readonly ControlOption[];
|
|
24
|
+
showLabel?: boolean;
|
|
24
25
|
value: string;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
type StaticSelectSize = "sm" | "default" | "lg" | "xl";
|
|
28
|
-
type SelectControlFieldLayout = "auto" | "stacked";
|
|
29
29
|
|
|
30
30
|
type SelectControlSingleProps = SelectControlInput & {
|
|
31
31
|
inputs?: never;
|
|
32
32
|
inputsPerRow?: never;
|
|
33
|
-
layout?: SelectControlFieldLayout;
|
|
34
33
|
};
|
|
35
34
|
|
|
36
35
|
type SelectControlGroupProps = {
|
|
@@ -43,7 +42,6 @@ export type SelectControlProps =
|
|
|
43
42
|
| SelectControlSingleProps
|
|
44
43
|
| SelectControlGroupProps;
|
|
45
44
|
|
|
46
|
-
const compactHorizontalFieldClassName = "h-fit min-w-0 items-center justify-between gap-3";
|
|
47
45
|
const WIDE_SELECT_OPTION_LABEL_LENGTH = 32;
|
|
48
46
|
|
|
49
47
|
function hasWideSelectContent(options: readonly ControlOption[]): boolean {
|
|
@@ -141,21 +139,15 @@ export function StaticSelect({
|
|
|
141
139
|
}
|
|
142
140
|
|
|
143
141
|
function SelectControlField({
|
|
144
|
-
layout = "auto",
|
|
145
142
|
name,
|
|
146
143
|
onValueChange,
|
|
147
144
|
options,
|
|
145
|
+
showLabel = true,
|
|
148
146
|
value,
|
|
149
|
-
}: SelectControlInput
|
|
150
|
-
layout?: SelectControlFieldLayout;
|
|
151
|
-
}): React.JSX.Element {
|
|
147
|
+
}: SelectControlInput): React.JSX.Element {
|
|
152
148
|
const fieldRef = React.useRef<HTMLDivElement>(null);
|
|
153
149
|
const popupMaxWidth = useMeasuredElementWidth(fieldRef);
|
|
154
150
|
const [currentValue, setCurrentValue] = React.useState(value);
|
|
155
|
-
const selected = options.find((option) => option.value === currentValue) ?? options[0];
|
|
156
|
-
const hasLongSelectedValue =
|
|
157
|
-
(selected?.label.length ?? 0) >= WIDE_SELECT_OPTION_LABEL_LENGTH;
|
|
158
|
-
const isStacked = layout === "stacked" || hasLongSelectedValue;
|
|
159
151
|
|
|
160
152
|
React.useEffect(() => {
|
|
161
153
|
setCurrentValue(value);
|
|
@@ -168,22 +160,14 @@ function SelectControlField({
|
|
|
168
160
|
|
|
169
161
|
return (
|
|
170
162
|
<Field
|
|
171
|
-
className={cn(
|
|
172
|
-
|
|
173
|
-
? "h-fit min-w-0 gap-2"
|
|
174
|
-
: compactHorizontalFieldClassName,
|
|
175
|
-
)}
|
|
176
|
-
orientation={isStacked ? "vertical" : "horizontal"}
|
|
163
|
+
className={cn("h-fit min-w-0", showLabel && "gap-2")}
|
|
164
|
+
orientation="vertical"
|
|
177
165
|
ref={fieldRef}
|
|
178
166
|
>
|
|
179
|
-
<ControlFieldLabel>{name}</ControlFieldLabel>
|
|
180
|
-
<div
|
|
181
|
-
className={cn(
|
|
182
|
-
"min-w-0",
|
|
183
|
-
isStacked ? "w-full" : "w-1/2 shrink-0",
|
|
184
|
-
)}
|
|
185
|
-
>
|
|
167
|
+
{showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
|
|
168
|
+
<div className="min-w-0 w-full">
|
|
186
169
|
<StaticSelect
|
|
170
|
+
ariaLabel={showLabel ? undefined : name}
|
|
187
171
|
onValueChange={updateValue}
|
|
188
172
|
options={options}
|
|
189
173
|
popupMaxWidth={popupMaxWidth}
|
|
@@ -211,7 +195,6 @@ export function SelectControl(
|
|
|
211
195
|
{props.inputs.map((input, index) => (
|
|
212
196
|
<SelectControlField
|
|
213
197
|
key={`${input.name}-${index}`}
|
|
214
|
-
layout="stacked"
|
|
215
198
|
{...input}
|
|
216
199
|
/>
|
|
217
200
|
))}
|
|
@@ -15,6 +15,7 @@ export type TextInputControlInput = {
|
|
|
15
15
|
defaultValue?: string;
|
|
16
16
|
name: string;
|
|
17
17
|
onValueChange?: ControlValueChangeHandler<string>;
|
|
18
|
+
showLabel?: boolean;
|
|
18
19
|
value: string;
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -51,6 +52,7 @@ function TextInputControlField({
|
|
|
51
52
|
defaultValue,
|
|
52
53
|
name,
|
|
53
54
|
onValueChange,
|
|
55
|
+
showLabel = true,
|
|
54
56
|
value,
|
|
55
57
|
}: TextInputControlInput): React.JSX.Element {
|
|
56
58
|
const [currentValue, setCurrentValue] = React.useState(value);
|
|
@@ -101,8 +103,9 @@ function TextInputControlField({
|
|
|
101
103
|
|
|
102
104
|
return (
|
|
103
105
|
<Field className="min-w-0 gap-2">
|
|
104
|
-
<ControlFieldLabel>{name}</ControlFieldLabel>
|
|
106
|
+
{showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
|
|
105
107
|
<Input
|
|
108
|
+
aria-label={showLabel ? undefined : name}
|
|
106
109
|
className="font-mono"
|
|
107
110
|
onBlur={commitOnBlur ? () => commitValue() : finishLiveHistoryGroup}
|
|
108
111
|
onChange={(event) => updateValue(event.target.value)}
|