@pixel-point/toolcraft 0.0.4 → 0.0.6
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 +6 -0
- package/templates/runtime/contracts/component-contracts.test.ts +59 -2
- package/templates/runtime/contracts/component-contracts.ts +23 -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 +269 -0
- package/templates/runtime/react/controls-panel.tsx +133 -24
- 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 +77 -1
- package/templates/runtime/schema/define-toolcraft.ts +138 -5
- package/templates/runtime/schema/runtime-targets.ts +1 -0
- package/templates/runtime/schema/types.ts +23 -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 +86 -8
- package/templates/runtime/state/reducer.ts +41 -22
- package/templates/runtime/state/types.ts +1 -0
- package/templates/starter/AGENTS.md +2 -2
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
- package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
- package/templates/starter/docs/toolcraft/component-rules.md +13 -1
- package/templates/starter/docs/toolcraft/performance.md +5 -0
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
- package/templates/starter/gitignore +36 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
- package/templates/starter/src/app/starter-acceptance.ts +67 -1
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
- 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
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type ColorOpacityValue,
|
|
43
43
|
type CurveInterpolation,
|
|
44
44
|
type FontPickerValue,
|
|
45
|
+
type FileDropPreview,
|
|
45
46
|
type GradientStop,
|
|
46
47
|
type GradientType,
|
|
47
48
|
type ImagePickerItem,
|
|
@@ -1691,12 +1692,94 @@ export function ControlsPanel({
|
|
|
1691
1692
|
);
|
|
1692
1693
|
}
|
|
1693
1694
|
|
|
1694
|
-
function
|
|
1695
|
-
|
|
1696
|
-
|
|
1695
|
+
function normalizeControlHelpContext(value: string): string {
|
|
1696
|
+
return value
|
|
1697
|
+
.toLowerCase()
|
|
1698
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
1699
|
+
.trim();
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function isColorSectionTitle(sectionTitle: string | undefined): boolean {
|
|
1703
|
+
return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function isSequentialColorLabel(label: string): boolean {
|
|
1707
|
+
return /^colou?r\s+\d+$/i.test(label.trim());
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function isSimplePaletteDistributionLabel(label: string): boolean {
|
|
1711
|
+
return /^(spread|mix|distribution)$/i.test(label.trim());
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function isGenericControlHelpDescription(description: string): boolean {
|
|
1715
|
+
return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
|
|
1716
|
+
description.trim(),
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function shouldSuppressObviousControlHelp({
|
|
1721
|
+
control,
|
|
1722
|
+
description,
|
|
1723
|
+
label,
|
|
1724
|
+
sectionTitle,
|
|
1725
|
+
}: {
|
|
1726
|
+
control: ToolcraftControlSchema;
|
|
1727
|
+
description: string;
|
|
1728
|
+
label: string;
|
|
1729
|
+
sectionTitle: string | undefined;
|
|
1730
|
+
}): boolean {
|
|
1731
|
+
const isColorControl = control.type === "color" || control.type === "colorOpacity";
|
|
1732
|
+
|
|
1733
|
+
if (isColorSectionTitle(sectionTitle)) {
|
|
1734
|
+
if (isColorControl && isSequentialColorLabel(label)) {
|
|
1735
|
+
return true;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (
|
|
1739
|
+
isSimplePaletteDistributionLabel(label) &&
|
|
1740
|
+
isGenericControlHelpDescription(description)
|
|
1741
|
+
) {
|
|
1742
|
+
return true;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const normalizedDescription = normalizeControlHelpContext(description);
|
|
1747
|
+
const normalizedLabel = normalizeControlHelpContext(label);
|
|
1748
|
+
|
|
1749
|
+
return (
|
|
1750
|
+
Boolean(normalizedLabel) &&
|
|
1751
|
+
isGenericControlHelpDescription(description) &&
|
|
1752
|
+
normalizedDescription === normalizedLabel
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function getControlHelpText({
|
|
1757
|
+
control,
|
|
1758
|
+
label,
|
|
1759
|
+
sectionTitle,
|
|
1760
|
+
}: {
|
|
1761
|
+
control: ToolcraftControlSchema;
|
|
1762
|
+
label: string;
|
|
1763
|
+
sectionTitle: string | undefined;
|
|
1764
|
+
}): string | null {
|
|
1697
1765
|
const description = control.description?.trim();
|
|
1698
1766
|
|
|
1699
|
-
|
|
1767
|
+
if (!description) {
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (
|
|
1772
|
+
shouldSuppressObviousControlHelp({
|
|
1773
|
+
control,
|
|
1774
|
+
description,
|
|
1775
|
+
label,
|
|
1776
|
+
sectionTitle,
|
|
1777
|
+
})
|
|
1778
|
+
) {
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
return description;
|
|
1700
1783
|
}
|
|
1701
1784
|
|
|
1702
1785
|
function withControlLabelHelp({
|
|
@@ -1704,13 +1787,15 @@ export function ControlsPanel({
|
|
|
1704
1787
|
control,
|
|
1705
1788
|
label,
|
|
1706
1789
|
providerKey,
|
|
1790
|
+
sectionTitle,
|
|
1707
1791
|
}: {
|
|
1708
1792
|
children: React.ReactNode;
|
|
1709
1793
|
control: ToolcraftControlSchema;
|
|
1710
1794
|
label: string;
|
|
1711
1795
|
providerKey: string;
|
|
1796
|
+
sectionTitle: string | undefined;
|
|
1712
1797
|
}): React.ReactNode {
|
|
1713
|
-
const help = getControlHelpText(control);
|
|
1798
|
+
const help = getControlHelpText({ control, label, sectionTitle });
|
|
1714
1799
|
|
|
1715
1800
|
if (!help) {
|
|
1716
1801
|
return children;
|
|
@@ -2129,13 +2214,38 @@ export function ControlsPanel({
|
|
|
2129
2214
|
}
|
|
2130
2215
|
|
|
2131
2216
|
case "fileDrop": {
|
|
2132
|
-
const
|
|
2133
|
-
|
|
2134
|
-
|
|
2217
|
+
const previewMediaAssets = state.schema.panels.layers ? [] : state.mediaAssets;
|
|
2218
|
+
const previewMediaAsset = previewMediaAssets[0];
|
|
2219
|
+
const previews = previewMediaAssets.map((asset): FileDropPreview => ({
|
|
2220
|
+
alt: asset.fileName,
|
|
2221
|
+
id: asset.id,
|
|
2222
|
+
size: asset.size,
|
|
2223
|
+
src: asset.dataUrl,
|
|
2224
|
+
}));
|
|
2225
|
+
const importFile = (file: File, replaceExisting: boolean): void => {
|
|
2226
|
+
void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
|
|
2227
|
+
if (!importedImage) {
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
dispatchCommand({
|
|
2232
|
+
asset: {
|
|
2233
|
+
dataUrl: importedImage.dataUrl,
|
|
2234
|
+
fileName: file.name,
|
|
2235
|
+
mimeType: file.type || "image/*",
|
|
2236
|
+
position: { x: 0, y: 0 },
|
|
2237
|
+
size: importedImage.size,
|
|
2238
|
+
},
|
|
2239
|
+
replaceExisting,
|
|
2240
|
+
type: "media.import",
|
|
2241
|
+
});
|
|
2242
|
+
});
|
|
2243
|
+
};
|
|
2135
2244
|
|
|
2136
2245
|
return (
|
|
2137
2246
|
<FileDrop
|
|
2138
2247
|
accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
|
|
2248
|
+
multiple={control.multiple}
|
|
2139
2249
|
key={id}
|
|
2140
2250
|
onClear={
|
|
2141
2251
|
previewMediaAsset
|
|
@@ -2147,33 +2257,31 @@ export function ControlsPanel({
|
|
|
2147
2257
|
}
|
|
2148
2258
|
: undefined
|
|
2149
2259
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
size: importedImage.size,
|
|
2163
|
-
},
|
|
2164
|
-
type: "media.import",
|
|
2165
|
-
});
|
|
2260
|
+
onFilesSelect={(files) => {
|
|
2261
|
+
files.forEach((file) => importFile(file, false));
|
|
2262
|
+
}}
|
|
2263
|
+
onFileSelect={(file) => importFile(file, true)}
|
|
2264
|
+
onPreviewRemove={(item) => {
|
|
2265
|
+
if (!item.id) {
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
dispatchCommand({
|
|
2270
|
+
mediaId: item.id,
|
|
2271
|
+
type: "media.delete",
|
|
2166
2272
|
});
|
|
2167
2273
|
}}
|
|
2168
2274
|
preview={
|
|
2169
2275
|
previewMediaAsset
|
|
2170
2276
|
? {
|
|
2277
|
+
id: previewMediaAsset.id,
|
|
2171
2278
|
alt: previewMediaAsset.fileName,
|
|
2172
2279
|
size: previewMediaAsset.size,
|
|
2173
2280
|
src: previewMediaAsset.dataUrl,
|
|
2174
2281
|
}
|
|
2175
2282
|
: undefined
|
|
2176
2283
|
}
|
|
2284
|
+
previews={previews}
|
|
2177
2285
|
/>
|
|
2178
2286
|
);
|
|
2179
2287
|
}
|
|
@@ -2514,6 +2622,7 @@ export function ControlsPanel({
|
|
|
2514
2622
|
control,
|
|
2515
2623
|
label: name,
|
|
2516
2624
|
providerKey: id,
|
|
2625
|
+
sectionTitle: section.title,
|
|
2517
2626
|
}),
|
|
2518
2627
|
control,
|
|
2519
2628
|
}),
|
|
@@ -131,10 +131,10 @@ describe("settings transfer", () => {
|
|
|
131
131
|
expect(state.values["generation.prompt"]).toBe("Imported prompt");
|
|
132
132
|
expect(state.values["unknown.target"]).toBeUndefined();
|
|
133
133
|
expect(state.values["canvas.aspectRatio"]).toEqual({
|
|
134
|
-
height:
|
|
134
|
+
height: 9,
|
|
135
135
|
mode: "custom",
|
|
136
|
-
value: "
|
|
137
|
-
width:
|
|
136
|
+
value: "16:9",
|
|
137
|
+
width: 16,
|
|
138
138
|
});
|
|
139
139
|
expect(state.canvas.size).toEqual({ height: 900, unit: "px", width: 1600 });
|
|
140
140
|
expect(state.timeline).toMatchObject({
|
|
@@ -114,6 +114,75 @@ describe("TimelinePanel", () => {
|
|
|
114
114
|
expect(screen.getByTestId("timeline-looping").textContent).toBe("false");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it("keeps timeline playhead handles easy to grab without changing visual size", () => {
|
|
118
|
+
const { container } = renderTimelinePanel();
|
|
119
|
+
|
|
120
|
+
const compactHandle = container.querySelector<HTMLElement>(
|
|
121
|
+
'[data-slot="timeline-playback-handle"]',
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(compactHandle?.className).toContain("size-2");
|
|
125
|
+
expect(compactHandle?.className).toContain("before:inset-[-4px]");
|
|
126
|
+
|
|
127
|
+
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
|
|
128
|
+
|
|
129
|
+
const expandedHitArea = container.querySelector<HTMLElement>(
|
|
130
|
+
'[data-slot="timeline-expanded-playhead-hit-area"]',
|
|
131
|
+
);
|
|
132
|
+
const expandedHandle = container.querySelector<HTMLElement>(
|
|
133
|
+
'[data-slot="timeline-expanded-playhead-handle"]',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(expandedHandle?.className).toContain("size-[9px]");
|
|
137
|
+
expect(expandedHitArea?.getAttribute("style")).toContain("width: 15px;");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("edits timeline duration from the highlighted duration value in collapsed and expanded states", () => {
|
|
141
|
+
const { container } = renderTimelinePanel();
|
|
142
|
+
|
|
143
|
+
const durationDisplay = container.querySelector<HTMLElement>(
|
|
144
|
+
'[data-slot="timeline-duration-display"]',
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(durationDisplay?.className).toContain("!cursor-text");
|
|
148
|
+
expect(durationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
|
|
149
|
+
expect(durationDisplay?.className).toContain("var(--foreground)_8%");
|
|
150
|
+
expect(durationDisplay?.className).toContain("px-1");
|
|
151
|
+
expect(container.querySelector('[data-slot="timeline-duration-edit-trigger"]')).toBeNull();
|
|
152
|
+
expect(screen.queryByRole("textbox", { name: "timeline duration" })).toBeNull();
|
|
153
|
+
|
|
154
|
+
fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
|
|
155
|
+
|
|
156
|
+
const editor = screen.getByRole("textbox", { name: "timeline duration" });
|
|
157
|
+
|
|
158
|
+
expect(editor.className).toContain("var(--foreground)_8%");
|
|
159
|
+
expect(editor.className).toContain("px-1");
|
|
160
|
+
expect(editor.className).toContain("!cursor-text");
|
|
161
|
+
expect(screen.queryByRole("button", { name: "Edit timeline duration" })).toBeNull();
|
|
162
|
+
|
|
163
|
+
editor.textContent = "12s";
|
|
164
|
+
fireEvent.keyDown(editor, { key: "Enter" });
|
|
165
|
+
|
|
166
|
+
expect(screen.getByText("12s")).toBeTruthy();
|
|
167
|
+
|
|
168
|
+
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
|
|
169
|
+
|
|
170
|
+
const expandedDurationDisplay = container.querySelector<HTMLElement>(
|
|
171
|
+
'[data-slot="timeline-duration-display"]',
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(expandedDurationDisplay?.className).toContain("!cursor-text");
|
|
175
|
+
expect(expandedDurationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
|
|
176
|
+
expect(expandedDurationDisplay?.className).toContain("var(--foreground)_8%");
|
|
177
|
+
|
|
178
|
+
fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
|
|
179
|
+
|
|
180
|
+
const expandedEditor = screen.getByRole("textbox", { name: "timeline duration" });
|
|
181
|
+
|
|
182
|
+
expect(expandedEditor.className).toContain("var(--foreground)_8%");
|
|
183
|
+
expect(expandedEditor.className).toContain("!cursor-text");
|
|
184
|
+
});
|
|
185
|
+
|
|
117
186
|
it("replays from the beginning when play is pressed at the non-looping end", () => {
|
|
118
187
|
renderTimelinePanel(
|
|
119
188
|
{},
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from 'react';
|
|
12
12
|
import {
|
|
13
13
|
Button,
|
|
14
|
-
EditableSliderValueLabel,
|
|
15
14
|
PanelSurface,
|
|
16
15
|
Popover,
|
|
17
16
|
PopoverContent,
|
|
@@ -74,7 +73,7 @@ const timelineRulerRightInsetPx = timelineTrackColumnBorderWidthPx / 2;
|
|
|
74
73
|
const timelineRowActionColumnWidthPx = 36;
|
|
75
74
|
const timelineExpandedTrackEndOffsetPx =
|
|
76
75
|
timelineTrackEndInsetPx + timelineRowActionColumnWidthPx + timelineTrackColumnBorderWidthPx;
|
|
77
|
-
const timelinePlayheadSafeZonePx =
|
|
76
|
+
const timelinePlayheadSafeZonePx = 7;
|
|
78
77
|
const timelinePlayheadHitAreaWidthPx =
|
|
79
78
|
timelineTrackColumnBorderWidthPx + timelinePlayheadSafeZonePx * 2;
|
|
80
79
|
const maxTimelineDurationSeconds = 60;
|
|
@@ -1667,7 +1666,7 @@ function TimelinePlaybackStrip({
|
|
|
1667
1666
|
<span
|
|
1668
1667
|
aria-hidden="true"
|
|
1669
1668
|
className={cn(
|
|
1670
|
-
'absolute bottom-px size-2 -translate-x-1/2 translate-y-1/2 rounded-full bg-[color:var(--link)] opacity-0 shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_70%,transparent)] transition-[opacity,transform] duration-[120ms] ease-out group-hover/timeline-panel-surface:opacity-100 group-hover/timeline-strip:opacity-100 group-focus-visible/timeline-strip:opacity-100',
|
|
1669
|
+
'absolute bottom-px size-2 -translate-x-1/2 translate-y-1/2 rounded-full bg-[color:var(--link)] opacity-0 shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_70%,transparent)] transition-[opacity,transform] duration-[120ms] ease-out before:absolute before:inset-[-4px] before:content-[""] group-hover/timeline-panel-surface:opacity-100 group-hover/timeline-strip:opacity-100 group-focus-visible/timeline-strip:opacity-100',
|
|
1671
1670
|
isScrubbing && 'scale-110 opacity-100',
|
|
1672
1671
|
)}
|
|
1673
1672
|
data-slot="timeline-playback-handle"
|
|
@@ -2498,6 +2497,99 @@ function useTimelineScrubber({
|
|
|
2498
2497
|
};
|
|
2499
2498
|
}
|
|
2500
2499
|
|
|
2500
|
+
function selectTimelineEditableText(node: HTMLElement): void {
|
|
2501
|
+
const selection = window.getSelection();
|
|
2502
|
+
|
|
2503
|
+
if (!selection) {
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
const range = document.createRange();
|
|
2508
|
+
range.selectNodeContents(node);
|
|
2509
|
+
selection.removeAllRanges();
|
|
2510
|
+
selection.addRange(range);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function TimelineDurationValue({
|
|
2514
|
+
durationSeconds,
|
|
2515
|
+
onCommit,
|
|
2516
|
+
}: {
|
|
2517
|
+
durationSeconds: number;
|
|
2518
|
+
onCommit: (value: string) => void;
|
|
2519
|
+
}): React.JSX.Element {
|
|
2520
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
2521
|
+
const editorRef = useRef<HTMLSpanElement>(null);
|
|
2522
|
+
const valueLabel = formatDurationValueLabel(durationSeconds);
|
|
2523
|
+
|
|
2524
|
+
useEffect(() => {
|
|
2525
|
+
if (!isEditing) {
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const editor = editorRef.current;
|
|
2530
|
+
|
|
2531
|
+
if (!editor) {
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
editor.textContent = valueLabel;
|
|
2536
|
+
editor.focus();
|
|
2537
|
+
selectTimelineEditableText(editor);
|
|
2538
|
+
}, [isEditing, valueLabel]);
|
|
2539
|
+
|
|
2540
|
+
function commitDraft(): void {
|
|
2541
|
+
onCommit(editorRef.current?.textContent ?? valueLabel);
|
|
2542
|
+
setIsEditing(false);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
if (isEditing) {
|
|
2546
|
+
return (
|
|
2547
|
+
<span
|
|
2548
|
+
aria-label="timeline duration"
|
|
2549
|
+
className="block h-5 min-w-[3ch] !cursor-text overflow-hidden whitespace-nowrap rounded bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] px-1 font-sans text-xs leading-5 text-[color:var(--foreground)] outline-none tabular-nums"
|
|
2550
|
+
contentEditable
|
|
2551
|
+
data-slot="timeline-duration-editor"
|
|
2552
|
+
key="duration-editor"
|
|
2553
|
+
onBlur={commitDraft}
|
|
2554
|
+
onFocus={(event) => selectTimelineEditableText(event.currentTarget)}
|
|
2555
|
+
onKeyDown={(event) => {
|
|
2556
|
+
if (event.key === 'Enter') {
|
|
2557
|
+
event.preventDefault();
|
|
2558
|
+
commitDraft();
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
if (event.key === 'Escape') {
|
|
2562
|
+
event.preventDefault();
|
|
2563
|
+
setIsEditing(false);
|
|
2564
|
+
}
|
|
2565
|
+
}}
|
|
2566
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2567
|
+
ref={editorRef}
|
|
2568
|
+
role="textbox"
|
|
2569
|
+
suppressContentEditableWarning
|
|
2570
|
+
tabIndex={0}
|
|
2571
|
+
/>
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
return (
|
|
2576
|
+
<button
|
|
2577
|
+
aria-label="Edit timeline duration"
|
|
2578
|
+
className="block h-5 min-w-[3ch] shrink-0 !cursor-text overflow-hidden rounded px-1 font-sans text-xs leading-5 text-[color:var(--muted-foreground)] tabular-nums transition-colors duration-150 ease-out group-hover/timeline-panel-header:bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] focus-visible:bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] focus-visible:outline-none"
|
|
2579
|
+
data-slot="timeline-duration-display"
|
|
2580
|
+
key="duration-display"
|
|
2581
|
+
onClick={(event) => {
|
|
2582
|
+
event.stopPropagation();
|
|
2583
|
+
setIsEditing(true);
|
|
2584
|
+
}}
|
|
2585
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2586
|
+
type="button"
|
|
2587
|
+
>
|
|
2588
|
+
{valueLabel}
|
|
2589
|
+
</button>
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2501
2593
|
function TimelinePanelHeader({
|
|
2502
2594
|
canExpand,
|
|
2503
2595
|
currentTimeSeconds,
|
|
@@ -2538,7 +2630,7 @@ function TimelinePanelHeader({
|
|
|
2538
2630
|
return (
|
|
2539
2631
|
<div
|
|
2540
2632
|
className={cn(
|
|
2541
|
-
'relative flex min-w-0 shrink-0 items-center gap-1',
|
|
2633
|
+
'group/timeline-panel-header relative flex min-w-0 shrink-0 items-center gap-1',
|
|
2542
2634
|
isExpanded
|
|
2543
2635
|
? 'h-9 border-b border-[color:color-mix(in_oklab,var(--border)_8%,transparent)] p-1'
|
|
2544
2636
|
: 'h-full',
|
|
@@ -2566,13 +2658,9 @@ function TimelinePanelHeader({
|
|
|
2566
2658
|
<TimelinePanelDivider />
|
|
2567
2659
|
<div className="ml-2 inline-flex shrink-0 items-center gap-1 text-xs leading-5 text-[color:color-mix(in_oklab,var(--foreground)_90%,transparent)]">
|
|
2568
2660
|
<span>{isExpanded ? 'Duration:' : 'Dur:'}</span>
|
|
2569
|
-
<
|
|
2570
|
-
|
|
2571
|
-
layout="content"
|
|
2572
|
-
maxValueLabel={`${maxTimelineDurationSeconds}s`}
|
|
2661
|
+
<TimelineDurationValue
|
|
2662
|
+
durationSeconds={durationSeconds}
|
|
2573
2663
|
onCommit={onDurationCommit}
|
|
2574
|
-
textAlign="left"
|
|
2575
|
-
valueLabel={formatDurationValueLabel(durationSeconds)}
|
|
2576
2664
|
/>
|
|
2577
2665
|
</div>
|
|
2578
2666
|
<span
|
|
@@ -92,25 +92,25 @@ describe("ToolbarPanel", () => {
|
|
|
92
92
|
it("dispatches zoom commands", () => {
|
|
93
93
|
renderToolbar();
|
|
94
94
|
|
|
95
|
-
expect(screen.getByText("
|
|
95
|
+
expect(screen.getByText("100%")).toBeTruthy();
|
|
96
96
|
|
|
97
97
|
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
98
|
-
expect(screen.getByText("
|
|
98
|
+
expect(screen.getByText("90%")).toBeTruthy();
|
|
99
99
|
|
|
100
100
|
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
101
101
|
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
102
|
-
expect(screen.getByText("
|
|
102
|
+
expect(screen.getByText("110%")).toBeTruthy();
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it("resets zoom from the zoom label double click", () => {
|
|
106
106
|
renderToolbar();
|
|
107
107
|
|
|
108
108
|
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
109
|
-
expect(screen.getByText("
|
|
109
|
+
expect(screen.getByText("110%")).toBeTruthy();
|
|
110
110
|
|
|
111
|
-
fireEvent.doubleClick(screen.getByText("
|
|
111
|
+
fireEvent.doubleClick(screen.getByText("110%"));
|
|
112
112
|
|
|
113
|
-
expect(screen.getByText("
|
|
113
|
+
expect(screen.getByText("100%")).toBeTruthy();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it("centers the canvas through the radar command", () => {
|
|
@@ -268,7 +268,7 @@ describe("assembled Toolcraft template runtime", () => {
|
|
|
268
268
|
expect(screen.getByText("Layers")).toBeTruthy();
|
|
269
269
|
expect(screen.getByText("Dur:")).toBeTruthy();
|
|
270
270
|
expect(screen.queryByText("Duration:")).toBeNull();
|
|
271
|
-
expect(toolbar?.textContent).toContain("
|
|
271
|
+
expect(toolbar?.textContent).toContain("100%");
|
|
272
272
|
expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled();
|
|
273
273
|
expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled();
|
|
274
274
|
expect(themeScope?.dataset.toolcraftTheme).toBe("dark");
|
|
@@ -305,7 +305,7 @@ describe("assembled Toolcraft template runtime", () => {
|
|
|
305
305
|
).toBe("320px");
|
|
306
306
|
|
|
307
307
|
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
308
|
-
expect(screen.getByText("
|
|
308
|
+
expect(screen.getByText("110%")).toBeTruthy();
|
|
309
309
|
|
|
310
310
|
const wheelEvent = new WheelEvent("wheel", {
|
|
311
311
|
bubbles: true,
|
|
@@ -47,7 +47,7 @@ describe("defineToolcraft", () => {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
expect(app.canvas.draggable).toBe(true);
|
|
50
|
-
expect(app.canvas.size).toEqual({ height:
|
|
50
|
+
expect(app.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
|
|
51
51
|
expect(app.canvas.sizeSource).toBe("runtime-default");
|
|
52
52
|
expect(app.canvas.sizing).toEqual({ mode: "intrinsic-media" });
|
|
53
53
|
expect(app.toolbar).toEqual({ history: true, radar: true, theme: true, zoom: true });
|
|
@@ -612,11 +612,13 @@ describe("defineToolcraft", () => {
|
|
|
612
612
|
it("publishes reserved targets for AI assembly boundaries", () => {
|
|
613
613
|
expect(toolcraftRuntimeOwnedTargets).toEqual([
|
|
614
614
|
"canvas.aspectRatio",
|
|
615
|
+
"canvas.renderScale",
|
|
615
616
|
"canvas.size.width",
|
|
616
617
|
"canvas.size.height",
|
|
617
618
|
]);
|
|
618
619
|
expect(toolcraftReservedTargets).toEqual([
|
|
619
620
|
"canvas.aspectRatio",
|
|
621
|
+
"canvas.renderScale",
|
|
620
622
|
"canvas.size.width",
|
|
621
623
|
"canvas.size.height",
|
|
622
624
|
"selectedLayer.opacity",
|
|
@@ -792,6 +794,80 @@ describe("defineToolcraft", () => {
|
|
|
792
794
|
expect(generationSection?.title).toBe("Generation");
|
|
793
795
|
});
|
|
794
796
|
|
|
797
|
+
it("appends raster render scale to the technical setup controls when enabled", () => {
|
|
798
|
+
const app = defineToolcraft({
|
|
799
|
+
canvas: {
|
|
800
|
+
enabled: true,
|
|
801
|
+
renderScale: true,
|
|
802
|
+
size: { height: 900, unit: "px", width: 1440 },
|
|
803
|
+
sizing: { mode: "editable-output" },
|
|
804
|
+
},
|
|
805
|
+
panels: {
|
|
806
|
+
controls: {
|
|
807
|
+
sections: [],
|
|
808
|
+
title: "Controls",
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
const setupSection = app.panels.controls?.sections[0];
|
|
814
|
+
|
|
815
|
+
expect(app.canvas.renderScale).toEqual({
|
|
816
|
+
defaultValue: 2,
|
|
817
|
+
enabled: true,
|
|
818
|
+
max: 2,
|
|
819
|
+
min: 1,
|
|
820
|
+
step: 0.25,
|
|
821
|
+
});
|
|
822
|
+
expect(app.assembly.capabilities).toContain("canvas.renderScale");
|
|
823
|
+
expect(Object.keys(setupSection?.controls ?? {})).toEqual([
|
|
824
|
+
"canvasAspectRatio",
|
|
825
|
+
"canvasWidth",
|
|
826
|
+
"canvasHeight",
|
|
827
|
+
"canvasRenderScale",
|
|
828
|
+
]);
|
|
829
|
+
expect(setupSection?.controls.canvasRenderScale).toMatchObject({
|
|
830
|
+
defaultValue: 2,
|
|
831
|
+
label: "Resolution scale",
|
|
832
|
+
markerCount: 5,
|
|
833
|
+
max: 2,
|
|
834
|
+
min: 1,
|
|
835
|
+
step: 0.25,
|
|
836
|
+
target: "canvas.renderScale",
|
|
837
|
+
type: "slider",
|
|
838
|
+
unit: "x",
|
|
839
|
+
variant: "discrete",
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("can prepend render scale without editable output sizing", () => {
|
|
844
|
+
const app = defineToolcraft({
|
|
845
|
+
canvas: {
|
|
846
|
+
enabled: true,
|
|
847
|
+
renderScale: { defaultValue: 1.5 },
|
|
848
|
+
size: { height: 900, unit: "px", width: 1440 },
|
|
849
|
+
sizing: { mode: "fixed-output" },
|
|
850
|
+
},
|
|
851
|
+
panels: {
|
|
852
|
+
controls: {
|
|
853
|
+
sections: [],
|
|
854
|
+
title: "Controls",
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(app.panels.controls?.sections[0]).toMatchObject({
|
|
860
|
+
controls: {
|
|
861
|
+
canvasRenderScale: {
|
|
862
|
+
defaultValue: 1.5,
|
|
863
|
+
target: "canvas.renderScale",
|
|
864
|
+
type: "slider",
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
title: "Setup",
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
795
871
|
it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
|
|
796
872
|
const intrinsicApp = defineToolcraft({
|
|
797
873
|
canvas: {
|