@pixel-point/toolcraft 0.0.9 → 0.0.12
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 +2 -1
- package/src/cli.mjs +4 -2
- package/src/cli.test.mjs +5 -2
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +33 -2
- package/templates/runtime/contracts/component-contracts.test.ts +175 -36
- package/templates/runtime/contracts/component-contracts.ts +90 -45
- package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
- package/templates/runtime/contracts/decision-contracts.ts +19 -6
- 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 +478 -27
- package/templates/runtime/react/controls-panel.tsx +71 -26
- 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 +225 -173
- package/templates/runtime/schema/define-toolcraft.ts +117 -247
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +41 -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 +657 -2
- package/templates/runtime/testing/performance.ts +789 -49
- package/templates/starter/AGENTS.md +22 -16
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
- package/templates/starter/docs/toolcraft/component-rules.md +58 -37
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +30 -10
- package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
- 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 +57 -4
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -2
- 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 +82 -16
- package/templates/starter/scripts/toolcraft-port.mjs +178 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
- package/templates/starter/src/app/starter-acceptance.ts +978 -81
- package/templates/starter/src/app/starter-performance.test.ts +130 -8
- 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/controls/actions/actions-control.tsx +46 -3
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- 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 +2 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
- package/templates/ui/components/controls/select/select-control.tsx +4 -26
- package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
|
@@ -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";
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { applySliderValueLabelUnit } from "../slider/slider-value";
|
|
2
2
|
|
|
3
|
+
const signedNumberPattern = "[-−]?\\d+(?:[.,]\\d+)?";
|
|
4
|
+
const rangePairPattern = new RegExp(
|
|
5
|
+
`(${signedNumberPattern})(?:\\s*[\\p{L}%°]+)?\\s*(?:/|[-‐‑‒–—−])\\s*(${signedNumberPattern})`,
|
|
6
|
+
"u",
|
|
7
|
+
);
|
|
8
|
+
|
|
3
9
|
export function formatRangeSliderValue(
|
|
4
10
|
value: readonly number[],
|
|
5
11
|
unit?: string,
|
|
@@ -44,11 +50,15 @@ export function parseRangeSliderDraft(
|
|
|
44
50
|
step: number;
|
|
45
51
|
},
|
|
46
52
|
): [number, number] | null {
|
|
47
|
-
const
|
|
48
|
-
const values =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
const pairMatch = draftValue.match(rangePairPattern);
|
|
54
|
+
const values = pairMatch
|
|
55
|
+
? [pairMatch[1], pairMatch[2]]
|
|
56
|
+
.map((match) => parseRangeSliderDraftNumber(match))
|
|
57
|
+
.filter(Number.isFinite)
|
|
58
|
+
: Array.from(
|
|
59
|
+
draftValue.matchAll(/[-−]?\d+(?:[.,]\d+)?/g),
|
|
60
|
+
([match]) => parseRangeSliderDraftNumber(match),
|
|
61
|
+
).filter(Number.isFinite);
|
|
52
62
|
|
|
53
63
|
if (values.length === 0) {
|
|
54
64
|
return null;
|
|
@@ -62,3 +72,7 @@ export function parseRangeSliderDraft(
|
|
|
62
72
|
|
|
63
73
|
return sortedValues;
|
|
64
74
|
}
|
|
75
|
+
|
|
76
|
+
function parseRangeSliderDraftNumber(value: string | undefined): number {
|
|
77
|
+
return Number(value?.replace("−", "-").replace(",", "."));
|
|
78
|
+
}
|
|
@@ -26,12 +26,10 @@ export type SelectControlInput = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
type StaticSelectSize = "sm" | "default" | "lg" | "xl";
|
|
29
|
-
type SelectControlFieldLayout = "auto" | "stacked";
|
|
30
29
|
|
|
31
30
|
type SelectControlSingleProps = SelectControlInput & {
|
|
32
31
|
inputs?: never;
|
|
33
32
|
inputsPerRow?: never;
|
|
34
|
-
layout?: SelectControlFieldLayout;
|
|
35
33
|
};
|
|
36
34
|
|
|
37
35
|
type SelectControlGroupProps = {
|
|
@@ -44,7 +42,6 @@ export type SelectControlProps =
|
|
|
44
42
|
| SelectControlSingleProps
|
|
45
43
|
| SelectControlGroupProps;
|
|
46
44
|
|
|
47
|
-
const compactHorizontalFieldClassName = "h-fit min-w-0 items-center justify-between gap-3";
|
|
48
45
|
const WIDE_SELECT_OPTION_LABEL_LENGTH = 32;
|
|
49
46
|
|
|
50
47
|
function hasWideSelectContent(options: readonly ControlOption[]): boolean {
|
|
@@ -142,22 +139,15 @@ export function StaticSelect({
|
|
|
142
139
|
}
|
|
143
140
|
|
|
144
141
|
function SelectControlField({
|
|
145
|
-
layout = "auto",
|
|
146
142
|
name,
|
|
147
143
|
onValueChange,
|
|
148
144
|
options,
|
|
149
145
|
showLabel = true,
|
|
150
146
|
value,
|
|
151
|
-
}: SelectControlInput
|
|
152
|
-
layout?: SelectControlFieldLayout;
|
|
153
|
-
}): React.JSX.Element {
|
|
147
|
+
}: SelectControlInput): React.JSX.Element {
|
|
154
148
|
const fieldRef = React.useRef<HTMLDivElement>(null);
|
|
155
149
|
const popupMaxWidth = useMeasuredElementWidth(fieldRef);
|
|
156
150
|
const [currentValue, setCurrentValue] = React.useState(value);
|
|
157
|
-
const selected = options.find((option) => option.value === currentValue) ?? options[0];
|
|
158
|
-
const hasLongSelectedValue =
|
|
159
|
-
(selected?.label.length ?? 0) >= WIDE_SELECT_OPTION_LABEL_LENGTH;
|
|
160
|
-
const isStacked = layout === "stacked" || hasLongSelectedValue;
|
|
161
151
|
|
|
162
152
|
React.useEffect(() => {
|
|
163
153
|
setCurrentValue(value);
|
|
@@ -170,23 +160,12 @@ function SelectControlField({
|
|
|
170
160
|
|
|
171
161
|
return (
|
|
172
162
|
<Field
|
|
173
|
-
className={cn(
|
|
174
|
-
|
|
175
|
-
? "h-fit min-w-0"
|
|
176
|
-
: isStacked
|
|
177
|
-
? "h-fit min-w-0 gap-2"
|
|
178
|
-
: compactHorizontalFieldClassName,
|
|
179
|
-
)}
|
|
180
|
-
orientation={!showLabel || isStacked ? "vertical" : "horizontal"}
|
|
163
|
+
className={cn("h-fit min-w-0", showLabel && "gap-2")}
|
|
164
|
+
orientation="vertical"
|
|
181
165
|
ref={fieldRef}
|
|
182
166
|
>
|
|
183
167
|
{showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
|
|
184
|
-
<div
|
|
185
|
-
className={cn(
|
|
186
|
-
"min-w-0",
|
|
187
|
-
!showLabel || isStacked ? "w-full" : "w-1/2 shrink-0",
|
|
188
|
-
)}
|
|
189
|
-
>
|
|
168
|
+
<div className="min-w-0 w-full">
|
|
190
169
|
<StaticSelect
|
|
191
170
|
ariaLabel={showLabel ? undefined : name}
|
|
192
171
|
onValueChange={updateValue}
|
|
@@ -216,7 +195,6 @@ export function SelectControl(
|
|
|
216
195
|
{props.inputs.map((input, index) => (
|
|
217
196
|
<SelectControlField
|
|
218
197
|
key={`${input.name}-${index}`}
|
|
219
|
-
layout="stacked"
|
|
220
198
|
{...input}
|
|
221
199
|
/>
|
|
222
200
|
))}
|
|
@@ -123,13 +123,25 @@ function getVectorPoint(
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
function getVectorValueLabel(x: string, y: string): string {
|
|
126
|
-
return `${x}, ${y}`;
|
|
126
|
+
return `${formatVectorPadCoordinate(x)}, ${formatVectorPadCoordinate(y)}`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function normalizeVectorCoordinate(value: string | undefined): string {
|
|
130
130
|
return typeof value === "string" && value.trim() ? value : "0.00";
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function formatVectorPadCoordinate(value: string | undefined): string {
|
|
134
|
+
const parsedValue = Number.parseFloat(normalizeVectorCoordinate(value));
|
|
135
|
+
|
|
136
|
+
if (!Number.isFinite(parsedValue)) {
|
|
137
|
+
return "0.00";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const roundedValue = Math.abs(parsedValue) < 0.005 ? 0 : clamp(parsedValue, -1, 1);
|
|
141
|
+
|
|
142
|
+
return roundedValue.toFixed(2);
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
function parseVectorValueDraft(value: string): VectorControlValue | null {
|
|
134
146
|
const matches = value.match(/[+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+)/g);
|
|
135
147
|
|
|
@@ -307,6 +319,7 @@ function VectorPadHandle({ isDragging }: { isDragging: boolean }): React.JSX.Ele
|
|
|
307
319
|
}
|
|
308
320
|
|
|
309
321
|
function VectorPadField({
|
|
322
|
+
defaultValue,
|
|
310
323
|
name,
|
|
311
324
|
onValueChange,
|
|
312
325
|
padCoordinateMode,
|
|
@@ -316,8 +329,8 @@ function VectorPadField({
|
|
|
316
329
|
y,
|
|
317
330
|
}: VectorControlProps): React.JSX.Element {
|
|
318
331
|
const [isPointerDragging, setIsPointerDragging] = React.useState(false);
|
|
319
|
-
const normalizedX =
|
|
320
|
-
const normalizedY =
|
|
332
|
+
const normalizedX = formatVectorPadCoordinate(x);
|
|
333
|
+
const normalizedY = formatVectorPadCoordinate(y);
|
|
321
334
|
const coordinateMode =
|
|
322
335
|
padCoordinateMode ?? getDefaultPadCoordinateMode(padVariant);
|
|
323
336
|
const point = getVectorPoint(normalizedX, normalizedY, coordinateMode);
|
|
@@ -326,6 +339,8 @@ function VectorPadField({
|
|
|
326
339
|
const axisLockRef = React.useRef<VectorPadAxisLock | null>(null);
|
|
327
340
|
const dragStartRef = React.useRef<VectorPadDragStart | null>(null);
|
|
328
341
|
const liveHistoryGroupRef = React.useRef<string | null>(null);
|
|
342
|
+
const resetX = formatVectorPadCoordinate(defaultValue?.x);
|
|
343
|
+
const resetY = formatVectorPadCoordinate(defaultValue?.y);
|
|
329
344
|
const updateVector = (
|
|
330
345
|
nextX: string,
|
|
331
346
|
nextY: string,
|
|
@@ -418,15 +433,21 @@ function VectorPadField({
|
|
|
418
433
|
<button
|
|
419
434
|
aria-label={`${name} X/Y pad`}
|
|
420
435
|
className={cn(
|
|
421
|
-
"relative w-full cursor-default! touch-none overflow-hidden rounded-[calc(var(--radius)+2px)] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_4%,transparent),color-mix(in_oklab,var(--foreground)_1%,transparent))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_12%,transparent)] focus-visible:outline-none",
|
|
436
|
+
"relative w-full cursor-default! touch-none overflow-hidden rounded-[calc(var(--radius)+2px)] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_4%,transparent),color-mix(in_oklab,var(--foreground)_1%,transparent))] select-none focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_12%,transparent)] focus-visible:outline-none",
|
|
422
437
|
padShape === "square" ? "aspect-square" : "h-[142px]",
|
|
423
438
|
)}
|
|
424
439
|
data-vector-pad-coordinate-mode={coordinateMode}
|
|
425
440
|
data-vector-pad-shape={padShape}
|
|
426
441
|
data-vector-pad-variant={padVariant}
|
|
442
|
+
onDoubleClick={(event) => {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
stopPointerDrag();
|
|
445
|
+
updateVector(resetX, resetY);
|
|
446
|
+
}}
|
|
427
447
|
onLostPointerCapture={stopPointerDrag}
|
|
428
448
|
onPointerCancel={stopPointerDrag}
|
|
429
449
|
onPointerDown={(event) => {
|
|
450
|
+
event.preventDefault();
|
|
430
451
|
setIsPointerDragging(true);
|
|
431
452
|
axisLockRef.current = null;
|
|
432
453
|
dragStartRef.current = {
|
|
@@ -85,7 +85,7 @@ function getPanelActionIcon(action: PanelActionOption): ReactNode {
|
|
|
85
85
|
|
|
86
86
|
const Icon = panelActionIconComponents[action.icon];
|
|
87
87
|
|
|
88
|
-
return <Icon data-icon="inline-start" />;
|
|
88
|
+
return <Icon data-icon="inline-start" data-icon-name={action.icon} />;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function getPanelActionAriaLabel(
|