@pixel-point/toolcraft 0.0.4 → 0.0.7
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/scripts/prepare-pack.mjs +5 -0
- package/src/generate.mjs +13 -0
- package/src/generate.test.mjs +7 -1
- package/templates/runtime/contracts/component-contracts.test.ts +96 -3
- package/templates/runtime/contracts/component-contracts.ts +75 -3
- package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
- package/templates/runtime/contracts/decision-contracts.ts +1 -1
- package/templates/runtime/react/canvas-shell.test.tsx +7 -7
- package/templates/runtime/react/controls-panel.test.tsx +455 -1
- package/templates/runtime/react/controls-panel.tsx +515 -30
- package/templates/runtime/react/settings-transfer.test.ts +3 -3
- package/templates/runtime/react/timeline-panel.test.tsx +69 -0
- package/templates/runtime/react/timeline-panel.tsx +98 -10
- package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
- package/templates/runtime/schema/define-toolcraft.test.ts +78 -1
- package/templates/runtime/schema/define-toolcraft.ts +145 -6
- package/templates/runtime/schema/runtime-targets.ts +1 -0
- package/templates/runtime/schema/types.ts +46 -1
- package/templates/runtime/state/canvas-zoom.ts +1 -1
- package/templates/runtime/state/create-template-state.test.ts +6 -6
- package/templates/runtime/state/reducer.test.ts +139 -8
- package/templates/runtime/state/reducer.ts +88 -22
- package/templates/runtime/state/types.ts +3 -0
- package/templates/starter/AGENTS.md +8 -8
- package/templates/starter/docs/toolcraft/README.md +4 -3
- package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
- package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
- package/templates/starter/docs/toolcraft/component-rules.md +17 -1
- package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
- package/templates/starter/docs/toolcraft/performance.md +8 -7
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
- package/templates/starter/docs/toolcraft/workflow.md +5 -8
- package/templates/starter/gitignore +36 -0
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
- package/templates/starter/src/app/starter-acceptance.ts +67 -1
- package/templates/starter/src/app/starter-performance.test.ts +1 -1
- 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 +101 -18
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
- package/templates/ui/components/controls/index.ts +8 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
- package/templates/ui/components/controls/slider/slider-value.ts +48 -5
- package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
- package/templates/ui/index.ts +1 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { CloudArrowUpIcon, XIcon } from "@phosphor-icons/react";
|
|
4
|
+
import { CloudArrowUpIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../../lib/utils";
|
|
7
7
|
import { Button, Field } from "../../primitives";
|
|
8
8
|
|
|
9
9
|
export type FileDropPreview = {
|
|
10
10
|
alt?: string;
|
|
11
|
+
id?: string;
|
|
11
12
|
size?: {
|
|
12
13
|
height: number;
|
|
13
14
|
width: number;
|
|
@@ -17,9 +18,13 @@ export type FileDropPreview = {
|
|
|
17
18
|
|
|
18
19
|
export type FileDropControlProps = {
|
|
19
20
|
accept: string;
|
|
21
|
+
multiple?: boolean;
|
|
20
22
|
onClear?: () => void;
|
|
21
23
|
onFileSelect?: (file: File) => void;
|
|
24
|
+
onFilesSelect?: (files: File[]) => void;
|
|
25
|
+
onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
|
|
22
26
|
preview?: FileDropPreview;
|
|
27
|
+
previews?: readonly FileDropPreview[];
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
function isDragLeavingCurrentTarget(event: React.DragEvent<HTMLElement>): boolean {
|
|
@@ -73,20 +78,38 @@ function getPreviewImageStyle(size: FileDropPreview["size"]): React.CSSPropertie
|
|
|
73
78
|
|
|
74
79
|
export function FileDropControl({
|
|
75
80
|
accept,
|
|
81
|
+
multiple = false,
|
|
76
82
|
onClear,
|
|
77
83
|
onFileSelect,
|
|
84
|
+
onFilesSelect,
|
|
85
|
+
onPreviewRemove,
|
|
78
86
|
preview,
|
|
87
|
+
previews,
|
|
79
88
|
}: FileDropControlProps): React.JSX.Element {
|
|
80
89
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
81
90
|
const [dragOver, setDragOver] = React.useState(false);
|
|
82
|
-
const
|
|
91
|
+
const previewItems = previews ?? (preview ? [preview] : []);
|
|
92
|
+
const hasPreview = previewItems.some((item) => Boolean(item.src));
|
|
93
|
+
const shouldRenderPreviewGrid = multiple && previewItems.length > 1;
|
|
83
94
|
|
|
84
|
-
function
|
|
85
|
-
|
|
95
|
+
function handleFiles(fileList: FileList | readonly File[] | undefined): void {
|
|
96
|
+
const files = Array.from(fileList ?? []);
|
|
97
|
+
|
|
98
|
+
if (files.length === 0) {
|
|
86
99
|
return;
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
if (multiple) {
|
|
103
|
+
if (onFilesSelect) {
|
|
104
|
+
onFilesSelect(files);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
files.forEach((file) => onFileSelect?.(file));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onFileSelect?.(files[0]);
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
function openFileDialog(): void {
|
|
@@ -109,15 +132,24 @@ export function FileDropControl({
|
|
|
109
132
|
aria-hidden="true"
|
|
110
133
|
className="hidden"
|
|
111
134
|
onChange={(event) => {
|
|
112
|
-
|
|
135
|
+
handleFiles(event.currentTarget.files ?? undefined);
|
|
113
136
|
event.currentTarget.value = "";
|
|
114
137
|
}}
|
|
138
|
+
multiple={multiple}
|
|
115
139
|
ref={inputRef}
|
|
116
140
|
tabIndex={-1}
|
|
117
141
|
type="file"
|
|
118
142
|
/>
|
|
119
143
|
<div
|
|
120
|
-
aria-label={
|
|
144
|
+
aria-label={
|
|
145
|
+
hasPreview
|
|
146
|
+
? multiple
|
|
147
|
+
? "Drop image files"
|
|
148
|
+
: "Replace image file"
|
|
149
|
+
: multiple
|
|
150
|
+
? "Browse image files"
|
|
151
|
+
: "Browse image file"
|
|
152
|
+
}
|
|
121
153
|
className={cn(
|
|
122
154
|
"group/file-upload relative flex min-h-16 w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-[color:color-mix(in_oklab,var(--border)_18%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] text-center shadow-none transition-[background-color,border-color,box-shadow] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] data-[drag-over=true]:border-[color:color-mix(in_oklab,var(--link)_28%,transparent)] data-[drag-over=true]:bg-[color:color-mix(in_oklab,var(--link)_13%,transparent)] data-[drag-over=true]:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--background)]",
|
|
123
155
|
hasPreview ? "overflow-hidden p-2" : "px-3 py-3",
|
|
@@ -140,23 +172,74 @@ export function FileDropControl({
|
|
|
140
172
|
onDrop={(event) => {
|
|
141
173
|
event.preventDefault();
|
|
142
174
|
setDragOver(false);
|
|
143
|
-
|
|
175
|
+
handleFiles(event.dataTransfer?.files ?? undefined);
|
|
144
176
|
}}
|
|
145
177
|
onKeyDown={handleDropTargetKeyDown}
|
|
146
178
|
role="button"
|
|
147
179
|
tabIndex={0}
|
|
148
180
|
>
|
|
149
|
-
{
|
|
181
|
+
{shouldRenderPreviewGrid ? (
|
|
182
|
+
<div
|
|
183
|
+
className="grid w-full grid-cols-4 gap-2"
|
|
184
|
+
data-slot="file-upload-preview-grid"
|
|
185
|
+
>
|
|
186
|
+
{previewItems.map((item, index) => (
|
|
187
|
+
<div
|
|
188
|
+
className="relative aspect-square min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)-4px)] bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)]"
|
|
189
|
+
data-slot="file-upload-preview-item"
|
|
190
|
+
key={item.id ?? `${item.src}-${index}`}
|
|
191
|
+
>
|
|
192
|
+
<img
|
|
193
|
+
alt={item.alt ?? ""}
|
|
194
|
+
className="size-full object-cover"
|
|
195
|
+
draggable={false}
|
|
196
|
+
height={item.size?.height}
|
|
197
|
+
src={item.src}
|
|
198
|
+
width={item.size?.width}
|
|
199
|
+
/>
|
|
200
|
+
{onPreviewRemove ? (
|
|
201
|
+
<Button
|
|
202
|
+
aria-label={`Remove ${item.alt ?? "image"}`}
|
|
203
|
+
className="absolute top-1 right-1"
|
|
204
|
+
onClick={(event) => {
|
|
205
|
+
event.stopPropagation();
|
|
206
|
+
onPreviewRemove(item, index);
|
|
207
|
+
}}
|
|
208
|
+
size="icon-sm"
|
|
209
|
+
type="button"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
>
|
|
212
|
+
<XIcon className="drop-shadow-[0_2px_1px_color-mix(in_oklab,var(--background)_80%,transparent)]" />
|
|
213
|
+
</Button>
|
|
214
|
+
) : null}
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
<button
|
|
218
|
+
aria-label="Add image files"
|
|
219
|
+
className="flex aspect-square min-w-0 items-center justify-center rounded-[calc(var(--radius-lg)-4px)] border border-[color:color-mix(in_oklab,var(--border)_5%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_4%,transparent)] text-[color:color-mix(in_oklab,var(--foreground)_65%,transparent)] transition-[background-color,border-color,color] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_7%,transparent)] hover:text-[color:var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)]"
|
|
220
|
+
data-slot="file-upload-add-preview"
|
|
221
|
+
onClick={(event) => {
|
|
222
|
+
event.stopPropagation();
|
|
223
|
+
openFileDialog();
|
|
224
|
+
}}
|
|
225
|
+
type="button"
|
|
226
|
+
>
|
|
227
|
+
<PlusIcon className="size-4" weight="regular" />
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
) : hasPreview ? (
|
|
150
231
|
<>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
232
|
+
{previewItems[0] ? (
|
|
233
|
+
<img
|
|
234
|
+
alt={previewItems[0].alt ?? ""}
|
|
235
|
+
className="block h-auto max-h-[196px] max-w-full rounded-[calc(var(--radius-lg)-4px)] object-contain"
|
|
236
|
+
draggable={false}
|
|
237
|
+
height={previewItems[0].size?.height}
|
|
238
|
+
src={previewItems[0].src}
|
|
239
|
+
style={getPreviewImageStyle(previewItems[0].size)}
|
|
240
|
+
width={previewItems[0].size?.width}
|
|
241
|
+
/>
|
|
242
|
+
) : null}
|
|
160
243
|
{onClear ? (
|
|
161
244
|
<Button
|
|
162
245
|
aria-label="Remove image"
|
|
@@ -92,7 +92,6 @@ const defaultFontPickerFontSizePx = 16;
|
|
|
92
92
|
const defaultFontPickerColor = "#FFFFFF";
|
|
93
93
|
const defaultFontPickerOpacity = 100;
|
|
94
94
|
const minFontPickerFontSizePx = 1;
|
|
95
|
-
const maxFontPickerFontSizePx = 512;
|
|
96
95
|
|
|
97
96
|
const menuItemInteractionClassName =
|
|
98
97
|
"hover:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] hover:text-[color:var(--foreground)] focus:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] focus:text-[color:var(--foreground)]";
|
|
@@ -254,10 +253,7 @@ function normalizeFontPickerFontSize(value: unknown): number {
|
|
|
254
253
|
return defaultFontPickerFontSizePx;
|
|
255
254
|
}
|
|
256
255
|
|
|
257
|
-
return Math.
|
|
258
|
-
maxFontPickerFontSizePx,
|
|
259
|
-
Math.max(minFontPickerFontSizePx, Math.round(value)),
|
|
260
|
-
);
|
|
256
|
+
return Math.max(minFontPickerFontSizePx, Math.round(value));
|
|
261
257
|
}
|
|
262
258
|
|
|
263
259
|
function getFontFamilyStyle(font: FontPickerFontCatalogEntry | null): React.CSSProperties | undefined {
|
|
@@ -1131,7 +1127,6 @@ export function FontPickerControl({
|
|
|
1131
1127
|
aria-label="Font size"
|
|
1132
1128
|
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
1133
1129
|
disabled={disabled}
|
|
1134
|
-
max={maxFontPickerFontSizePx}
|
|
1135
1130
|
min={minFontPickerFontSizePx}
|
|
1136
1131
|
onBlur={() => commitFontSizeDraft()}
|
|
1137
1132
|
onChange={(event) => setFontSizeDraft(event.target.value)}
|
|
@@ -84,6 +84,14 @@ export type {
|
|
|
84
84
|
ChannelMixerControlProps as ChannelMixerProps,
|
|
85
85
|
ChannelMixerValues,
|
|
86
86
|
} from "./channel-mixer";
|
|
87
|
+
export {
|
|
88
|
+
CollectionActionsControl,
|
|
89
|
+
CollectionActionsControl as CollectionActions,
|
|
90
|
+
} from "./collection-actions";
|
|
91
|
+
export type {
|
|
92
|
+
CollectionActionsControlProps,
|
|
93
|
+
CollectionActionsControlProps as CollectionActionsProps,
|
|
94
|
+
} from "./collection-actions";
|
|
87
95
|
export { createControlHistoryGroupId } from "./control-types";
|
|
88
96
|
export type {
|
|
89
97
|
ControlChangeHistoryMode,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { applySliderValueLabelUnit } from "../slider/slider-value";
|
|
2
|
+
|
|
1
3
|
export function formatRangeSliderValue(
|
|
2
4
|
value: readonly number[],
|
|
3
5
|
unit?: string,
|
|
4
6
|
): string {
|
|
5
|
-
const formatValue = (item: number): string =>
|
|
7
|
+
const formatValue = (item: number): string =>
|
|
8
|
+
applySliderValueLabelUnit(String(Math.round(item)), unit);
|
|
6
9
|
|
|
7
10
|
if (value.length >= 2 && value[0] === value[1]) {
|
|
8
11
|
return formatValue(value[0] ?? 0);
|
|
@@ -17,19 +17,62 @@ export function formatSliderValue(value: number, step: number): string {
|
|
|
17
17
|
return String(rounded);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const compactSliderValueUnits = new Set([
|
|
21
|
+
"%",
|
|
22
|
+
"°",
|
|
23
|
+
"px",
|
|
24
|
+
"em",
|
|
25
|
+
"rem",
|
|
26
|
+
"vw",
|
|
27
|
+
"vh",
|
|
28
|
+
"vmin",
|
|
29
|
+
"vmax",
|
|
30
|
+
"x",
|
|
31
|
+
"s",
|
|
32
|
+
"ms",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function escapeRegExp(value: string): string {
|
|
36
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getSliderValueUnitSeparator(unit: string): "" | " " {
|
|
40
|
+
const normalizedUnit = unit.trim();
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
normalizedUnit === "" ||
|
|
44
|
+
compactSliderValueUnits.has(normalizedUnit) ||
|
|
45
|
+
/^[^\p{Letter}\p{Number}]+$/u.test(normalizedUnit)
|
|
46
|
+
) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return " ";
|
|
51
|
+
}
|
|
52
|
+
|
|
20
53
|
export function applySliderValueLabelUnit(
|
|
21
54
|
valueLabel: string,
|
|
22
55
|
unit?: string,
|
|
23
56
|
): string {
|
|
24
|
-
|
|
57
|
+
const normalizedUnit = unit?.trim();
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
!normalizedUnit ||
|
|
61
|
+
typeof parseSliderValueLabel(valueLabel) !== "number"
|
|
62
|
+
) {
|
|
25
63
|
return valueLabel;
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
const separator = getSliderValueUnitSeparator(normalizedUnit);
|
|
67
|
+
const unitPattern = new RegExp(
|
|
68
|
+
`(-?\\d+(?:\\.\\d+)?)(?:\\s*${escapeRegExp(normalizedUnit)})?`,
|
|
69
|
+
"g",
|
|
70
|
+
);
|
|
30
71
|
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
return valueLabel.replaceAll(
|
|
73
|
+
unitPattern,
|
|
74
|
+
(_match, value: string) => `${value}${separator}${normalizedUnit}`,
|
|
75
|
+
);
|
|
33
76
|
}
|
|
34
77
|
|
|
35
78
|
export function formatSliderValueWithUnit(
|
|
@@ -40,6 +40,7 @@ export function EditableSliderValueLabel({
|
|
|
40
40
|
const [editing, setEditing] = useState(false);
|
|
41
41
|
const editorRef = useRef<HTMLSpanElement>(null);
|
|
42
42
|
const valueLabelRef = useRef(valueLabel);
|
|
43
|
+
const isEditableValueLabel = hasEditableNumericValueLabel(valueLabel);
|
|
43
44
|
const valueTextClassName = getEditableValueTextClassName({ layout, textAlign });
|
|
44
45
|
const widestValueLabel = getWidestValueLabel(valueLabel, maxValueLabel);
|
|
45
46
|
|
|
@@ -61,7 +62,7 @@ export function EditableSliderValueLabel({
|
|
|
61
62
|
}
|
|
62
63
|
}, [editing]);
|
|
63
64
|
|
|
64
|
-
if (disabled || !onCommit) {
|
|
65
|
+
if (disabled || !onCommit || !isEditableValueLabel) {
|
|
65
66
|
const valueTextToneClassName = disabled
|
|
66
67
|
? "text-[color:color-mix(in_oklab,var(--foreground)_60%,transparent)] opacity-60"
|
|
67
68
|
: "text-[color:var(--muted-foreground)]";
|
|
@@ -323,6 +324,10 @@ function getWidestValueLabel(valueLabel: string, maxValueLabel?: string): string
|
|
|
323
324
|
return maxValueLabel;
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
function hasEditableNumericValueLabel(valueLabel: string): boolean {
|
|
328
|
+
return /-?\d+(?:\.\d+)?/.test(valueLabel);
|
|
329
|
+
}
|
|
330
|
+
|
|
326
331
|
function selectEditableText(node: HTMLElement): void {
|
|
327
332
|
const selection = window.getSelection();
|
|
328
333
|
|