@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
|
@@ -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 });
|
|
@@ -535,6 +535,7 @@ describe("defineToolcraft", () => {
|
|
|
535
535
|
expect(app.assembly.commands).toEqual(
|
|
536
536
|
expect.arrayContaining([
|
|
537
537
|
"controls.reset",
|
|
538
|
+
"controls.resetTargets",
|
|
538
539
|
"history.undo",
|
|
539
540
|
"media.delete",
|
|
540
541
|
"media.import",
|
|
@@ -612,11 +613,13 @@ describe("defineToolcraft", () => {
|
|
|
612
613
|
it("publishes reserved targets for AI assembly boundaries", () => {
|
|
613
614
|
expect(toolcraftRuntimeOwnedTargets).toEqual([
|
|
614
615
|
"canvas.aspectRatio",
|
|
616
|
+
"canvas.renderScale",
|
|
615
617
|
"canvas.size.width",
|
|
616
618
|
"canvas.size.height",
|
|
617
619
|
]);
|
|
618
620
|
expect(toolcraftReservedTargets).toEqual([
|
|
619
621
|
"canvas.aspectRatio",
|
|
622
|
+
"canvas.renderScale",
|
|
620
623
|
"canvas.size.width",
|
|
621
624
|
"canvas.size.height",
|
|
622
625
|
"selectedLayer.opacity",
|
|
@@ -792,6 +795,80 @@ describe("defineToolcraft", () => {
|
|
|
792
795
|
expect(generationSection?.title).toBe("Generation");
|
|
793
796
|
});
|
|
794
797
|
|
|
798
|
+
it("appends raster render scale to the technical setup controls when enabled", () => {
|
|
799
|
+
const app = defineToolcraft({
|
|
800
|
+
canvas: {
|
|
801
|
+
enabled: true,
|
|
802
|
+
renderScale: true,
|
|
803
|
+
size: { height: 900, unit: "px", width: 1440 },
|
|
804
|
+
sizing: { mode: "editable-output" },
|
|
805
|
+
},
|
|
806
|
+
panels: {
|
|
807
|
+
controls: {
|
|
808
|
+
sections: [],
|
|
809
|
+
title: "Controls",
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const setupSection = app.panels.controls?.sections[0];
|
|
815
|
+
|
|
816
|
+
expect(app.canvas.renderScale).toEqual({
|
|
817
|
+
defaultValue: 2,
|
|
818
|
+
enabled: true,
|
|
819
|
+
max: 2,
|
|
820
|
+
min: 1,
|
|
821
|
+
step: 0.25,
|
|
822
|
+
});
|
|
823
|
+
expect(app.assembly.capabilities).toContain("canvas.renderScale");
|
|
824
|
+
expect(Object.keys(setupSection?.controls ?? {})).toEqual([
|
|
825
|
+
"canvasAspectRatio",
|
|
826
|
+
"canvasWidth",
|
|
827
|
+
"canvasHeight",
|
|
828
|
+
"canvasRenderScale",
|
|
829
|
+
]);
|
|
830
|
+
expect(setupSection?.controls.canvasRenderScale).toMatchObject({
|
|
831
|
+
defaultValue: 2,
|
|
832
|
+
label: "Resolution scale",
|
|
833
|
+
markerCount: 5,
|
|
834
|
+
max: 2,
|
|
835
|
+
min: 1,
|
|
836
|
+
step: 0.25,
|
|
837
|
+
target: "canvas.renderScale",
|
|
838
|
+
type: "slider",
|
|
839
|
+
unit: "x",
|
|
840
|
+
variant: "discrete",
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("can prepend render scale without editable output sizing", () => {
|
|
845
|
+
const app = defineToolcraft({
|
|
846
|
+
canvas: {
|
|
847
|
+
enabled: true,
|
|
848
|
+
renderScale: { defaultValue: 1.5 },
|
|
849
|
+
size: { height: 900, unit: "px", width: 1440 },
|
|
850
|
+
sizing: { mode: "fixed-output" },
|
|
851
|
+
},
|
|
852
|
+
panels: {
|
|
853
|
+
controls: {
|
|
854
|
+
sections: [],
|
|
855
|
+
title: "Controls",
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
expect(app.panels.controls?.sections[0]).toMatchObject({
|
|
861
|
+
controls: {
|
|
862
|
+
canvasRenderScale: {
|
|
863
|
+
defaultValue: 1.5,
|
|
864
|
+
target: "canvas.renderScale",
|
|
865
|
+
type: "slider",
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
title: "Setup",
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
795
872
|
it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
|
|
796
873
|
const intrinsicApp = defineToolcraft({
|
|
797
874
|
canvas: {
|
|
@@ -26,9 +26,9 @@ import type {
|
|
|
26
26
|
} from "./types";
|
|
27
27
|
|
|
28
28
|
const defaultCanvasSize = {
|
|
29
|
-
height:
|
|
29
|
+
height: 1080,
|
|
30
30
|
unit: "px",
|
|
31
|
-
width:
|
|
31
|
+
width: 1920,
|
|
32
32
|
} satisfies ToolcraftCanvasSize;
|
|
33
33
|
|
|
34
34
|
type ResolvedCanvas = ResolvedToolcraftAppSchema["canvas"];
|
|
@@ -46,12 +46,21 @@ const canvasSizeControlTargets = {
|
|
|
46
46
|
width: "canvas.size.width",
|
|
47
47
|
} as const;
|
|
48
48
|
const canvasAspectRatioTarget = "canvas.aspectRatio";
|
|
49
|
+
const canvasRenderScaleTarget = "canvas.renderScale";
|
|
50
|
+
const defaultCanvasRenderScale = {
|
|
51
|
+
defaultValue: 2,
|
|
52
|
+
enabled: false,
|
|
53
|
+
max: 2,
|
|
54
|
+
min: 1,
|
|
55
|
+
step: 0.25,
|
|
56
|
+
} satisfies ResolvedToolcraftAppSchema["canvas"]["renderScale"];
|
|
49
57
|
const maxAutoInlineControlLabelLength = 18;
|
|
50
58
|
const settingsTransferTarget = "runtime.settingsTransfer";
|
|
51
59
|
const runtimeSetupSectionTitle = "Setup";
|
|
52
60
|
const settingsTransferHeavyControlTypes = new Set([
|
|
53
61
|
"channelMixer",
|
|
54
62
|
"code",
|
|
63
|
+
"collectionActions",
|
|
55
64
|
"colorOpacity",
|
|
56
65
|
"curves",
|
|
57
66
|
"fileDrop",
|
|
@@ -190,6 +199,54 @@ function resolveCanvasSizing(
|
|
|
190
199
|
return { mode: "intrinsic-media" };
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
function clampCanvasRenderScale(value: number | undefined, fallback: number): number {
|
|
203
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
204
|
+
return fallback;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return Math.max(1, Math.min(2, value));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveCanvasRenderScale(
|
|
211
|
+
renderScale: ToolcraftAppSchema["canvas"]["renderScale"],
|
|
212
|
+
): ResolvedToolcraftAppSchema["canvas"]["renderScale"] {
|
|
213
|
+
if (renderScale === true) {
|
|
214
|
+
return {
|
|
215
|
+
...defaultCanvasRenderScale,
|
|
216
|
+
enabled: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!renderScale) {
|
|
221
|
+
return defaultCanvasRenderScale;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const min = clampCanvasRenderScale(renderScale.min, defaultCanvasRenderScale.min);
|
|
225
|
+
const max = Math.max(
|
|
226
|
+
min,
|
|
227
|
+
clampCanvasRenderScale(renderScale.max, defaultCanvasRenderScale.max),
|
|
228
|
+
);
|
|
229
|
+
const step =
|
|
230
|
+
typeof renderScale.step === "number" && Number.isFinite(renderScale.step)
|
|
231
|
+
? Math.max(0.01, Math.min(1, renderScale.step))
|
|
232
|
+
: defaultCanvasRenderScale.step;
|
|
233
|
+
const defaultValue = Math.max(
|
|
234
|
+
min,
|
|
235
|
+
Math.min(
|
|
236
|
+
max,
|
|
237
|
+
clampCanvasRenderScale(renderScale.defaultValue, defaultCanvasRenderScale.defaultValue),
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
defaultValue,
|
|
243
|
+
enabled: renderScale.enabled ?? true,
|
|
244
|
+
max,
|
|
245
|
+
min,
|
|
246
|
+
step,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
193
250
|
function resolveExport(
|
|
194
251
|
exportSchema: ToolcraftAppSchema["export"],
|
|
195
252
|
): ResolvedExport {
|
|
@@ -264,6 +321,10 @@ function createToolcraftAssembly({
|
|
|
264
321
|
commands.push("canvas.setSize");
|
|
265
322
|
}
|
|
266
323
|
|
|
324
|
+
if (canvas.renderScale.enabled) {
|
|
325
|
+
capabilities.push("canvas.renderScale");
|
|
326
|
+
}
|
|
327
|
+
|
|
267
328
|
if (canvas.draggable) {
|
|
268
329
|
capabilities.push("canvas.draggable");
|
|
269
330
|
commands.push("canvas.panBy", "canvas.setOffset", "canvas.setViewport");
|
|
@@ -278,7 +339,12 @@ function createToolcraftAssembly({
|
|
|
278
339
|
const controlsPanel = panels.controls
|
|
279
340
|
? createPanelAssemblyContract({
|
|
280
341
|
capabilities: ["controls.panel", "controls.defaults"],
|
|
281
|
-
commands: [
|
|
342
|
+
commands: [
|
|
343
|
+
"controls.apply",
|
|
344
|
+
"controls.reset",
|
|
345
|
+
"controls.resetTargets",
|
|
346
|
+
"controls.setValue",
|
|
347
|
+
],
|
|
282
348
|
contract: TOOLCRAFT_COMPONENT_CONTRACTS.controlsPanel,
|
|
283
349
|
enabled: true,
|
|
284
350
|
})
|
|
@@ -742,10 +808,12 @@ function getCanvasAspectRatioDefaultValue(size: ToolcraftCanvasSize): {
|
|
|
742
808
|
|
|
743
809
|
function createCanvasSizeSection({
|
|
744
810
|
aspectRatioControl,
|
|
811
|
+
renderScaleControl,
|
|
745
812
|
sizeControlIds,
|
|
746
813
|
sizeControls,
|
|
747
814
|
}: {
|
|
748
815
|
aspectRatioControl: ToolcraftControlSchema;
|
|
816
|
+
renderScaleControl?: ToolcraftControlSchema;
|
|
749
817
|
sizeControlIds: readonly string[];
|
|
750
818
|
sizeControls: ToolcraftControlSectionSchema["controls"];
|
|
751
819
|
}): ToolcraftControlSectionSchema {
|
|
@@ -753,19 +821,33 @@ function createCanvasSizeSection({
|
|
|
753
821
|
controls: {
|
|
754
822
|
canvasAspectRatio: aspectRatioControl,
|
|
755
823
|
...sizeControls,
|
|
824
|
+
...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
|
|
756
825
|
},
|
|
757
826
|
layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
|
|
758
827
|
title: runtimeSetupSectionTitle,
|
|
759
828
|
};
|
|
760
829
|
}
|
|
761
830
|
|
|
831
|
+
function createCanvasRenderScaleSection(
|
|
832
|
+
renderScaleControl: ToolcraftControlSchema,
|
|
833
|
+
): ToolcraftControlSectionSchema {
|
|
834
|
+
return {
|
|
835
|
+
controls: {
|
|
836
|
+
canvasRenderScale: renderScaleControl,
|
|
837
|
+
},
|
|
838
|
+
title: runtimeSetupSectionTitle,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
762
842
|
function mergeCanvasSizeControlsIntoSettingsTransferSection({
|
|
763
843
|
aspectRatioControl,
|
|
844
|
+
renderScaleControl,
|
|
764
845
|
settingsTransferSection,
|
|
765
846
|
sizeControlIds,
|
|
766
847
|
sizeControls,
|
|
767
848
|
}: {
|
|
768
849
|
aspectRatioControl: ToolcraftControlSchema;
|
|
850
|
+
renderScaleControl?: ToolcraftControlSchema;
|
|
769
851
|
settingsTransferSection: ToolcraftControlSectionSchema;
|
|
770
852
|
sizeControlIds: readonly string[];
|
|
771
853
|
sizeControls: ToolcraftControlSectionSchema["controls"];
|
|
@@ -778,6 +860,7 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
|
|
|
778
860
|
...settingsTransferSection.controls,
|
|
779
861
|
canvasAspectRatio: aspectRatioControl,
|
|
780
862
|
...sizeControls,
|
|
863
|
+
...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
|
|
781
864
|
},
|
|
782
865
|
layoutGroups:
|
|
783
866
|
canvasSizeLayoutGroups.length > 0 || settingsTransferSection.layoutGroups?.length
|
|
@@ -789,6 +872,22 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
|
|
|
789
872
|
};
|
|
790
873
|
}
|
|
791
874
|
|
|
875
|
+
function mergeCanvasRenderScaleIntoSettingsTransferSection({
|
|
876
|
+
renderScaleControl,
|
|
877
|
+
settingsTransferSection,
|
|
878
|
+
}: {
|
|
879
|
+
renderScaleControl: ToolcraftControlSchema;
|
|
880
|
+
settingsTransferSection: ToolcraftControlSectionSchema;
|
|
881
|
+
}): ToolcraftControlSectionSchema {
|
|
882
|
+
return {
|
|
883
|
+
...settingsTransferSection,
|
|
884
|
+
controls: {
|
|
885
|
+
...settingsTransferSection.controls,
|
|
886
|
+
canvasRenderScale: renderScaleControl,
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
792
891
|
function isPrimaryPanelAction(action: ToolcraftControlActionSchema): boolean {
|
|
793
892
|
return typeof action !== "string" && action.variant !== "outline";
|
|
794
893
|
}
|
|
@@ -1308,14 +1407,46 @@ function normalizePanels({
|
|
|
1308
1407
|
|
|
1309
1408
|
const controls = { ...panels.controls };
|
|
1310
1409
|
const settingsTransferSection = createSettingsTransferSection(settingsTransfer);
|
|
1410
|
+
const renderScaleControl: ToolcraftControlSchema | undefined =
|
|
1411
|
+
canvas.renderScale.enabled && !hasControlTarget(panels, canvasRenderScaleTarget)
|
|
1412
|
+
? {
|
|
1413
|
+
defaultValue: canvas.renderScale.defaultValue,
|
|
1414
|
+
description:
|
|
1415
|
+
"Increases raster canvas backing resolution without changing the visible output size.",
|
|
1416
|
+
label: "Resolution scale",
|
|
1417
|
+
markerCount:
|
|
1418
|
+
Math.floor(
|
|
1419
|
+
(canvas.renderScale.max - canvas.renderScale.min) / canvas.renderScale.step,
|
|
1420
|
+
) + 1,
|
|
1421
|
+
max: canvas.renderScale.max,
|
|
1422
|
+
min: canvas.renderScale.min,
|
|
1423
|
+
performanceReason:
|
|
1424
|
+
"Resolution scale changes raster, Canvas, WebGL, or WebGPU backing pixels.",
|
|
1425
|
+
performanceRole: "workload",
|
|
1426
|
+
step: canvas.renderScale.step,
|
|
1427
|
+
target: canvasRenderScaleTarget,
|
|
1428
|
+
type: "slider",
|
|
1429
|
+
unit: "x",
|
|
1430
|
+
variant: "discrete",
|
|
1431
|
+
}
|
|
1432
|
+
: undefined;
|
|
1311
1433
|
|
|
1312
1434
|
if (!canvas.enabled || canvas.sizing.mode !== "editable-output") {
|
|
1435
|
+
const runtimeSetupSection =
|
|
1436
|
+
settingsTransferSection && renderScaleControl
|
|
1437
|
+
? mergeCanvasRenderScaleIntoSettingsTransferSection({
|
|
1438
|
+
renderScaleControl,
|
|
1439
|
+
settingsTransferSection,
|
|
1440
|
+
})
|
|
1441
|
+
: (settingsTransferSection ??
|
|
1442
|
+
(renderScaleControl ? createCanvasRenderScaleSection(renderScaleControl) : null));
|
|
1443
|
+
|
|
1313
1444
|
return {
|
|
1314
1445
|
...normalizedPanels,
|
|
1315
1446
|
controls: normalizeControlsPanelLayout({
|
|
1316
1447
|
...controls,
|
|
1317
1448
|
sections: [
|
|
1318
|
-
...(
|
|
1449
|
+
...(runtimeSetupSection ? [runtimeSetupSection] : []),
|
|
1319
1450
|
...controls.sections,
|
|
1320
1451
|
],
|
|
1321
1452
|
}),
|
|
@@ -1357,7 +1488,7 @@ function normalizePanels({
|
|
|
1357
1488
|
sizeControlIds.push("canvasHeight");
|
|
1358
1489
|
}
|
|
1359
1490
|
|
|
1360
|
-
if (Object.keys(sizeControls).length === 0) {
|
|
1491
|
+
if (Object.keys(sizeControls).length === 0 && !renderScaleControl) {
|
|
1361
1492
|
return {
|
|
1362
1493
|
...normalizedPanels,
|
|
1363
1494
|
controls: normalizeControlsPanelLayout({
|
|
@@ -1373,11 +1504,17 @@ function normalizePanels({
|
|
|
1373
1504
|
const runtimeSettingsSection = settingsTransferSection
|
|
1374
1505
|
? mergeCanvasSizeControlsIntoSettingsTransferSection({
|
|
1375
1506
|
aspectRatioControl,
|
|
1507
|
+
renderScaleControl,
|
|
1376
1508
|
settingsTransferSection,
|
|
1377
1509
|
sizeControlIds,
|
|
1378
1510
|
sizeControls,
|
|
1379
1511
|
})
|
|
1380
|
-
: createCanvasSizeSection({
|
|
1512
|
+
: createCanvasSizeSection({
|
|
1513
|
+
aspectRatioControl,
|
|
1514
|
+
renderScaleControl,
|
|
1515
|
+
sizeControlIds,
|
|
1516
|
+
sizeControls,
|
|
1517
|
+
});
|
|
1381
1518
|
|
|
1382
1519
|
return {
|
|
1383
1520
|
...normalizedPanels,
|
|
@@ -1453,6 +1590,7 @@ function assertPanelPersistenceContract({
|
|
|
1453
1590
|
export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAppSchema {
|
|
1454
1591
|
const canvasEnabled = schema.canvas.enabled;
|
|
1455
1592
|
const canvasSize = schema.canvas.size;
|
|
1593
|
+
const canvasRenderScale = resolveCanvasRenderScale(schema.canvas.renderScale);
|
|
1456
1594
|
const canvasSizing = resolveCanvasSizing(schema.canvas);
|
|
1457
1595
|
const persistence = resolvePersistence(schema.persistence);
|
|
1458
1596
|
const settingsTransfer = resolveSettingsTransfer({
|
|
@@ -1464,6 +1602,7 @@ export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAp
|
|
|
1464
1602
|
const canvas = {
|
|
1465
1603
|
...schema.canvas,
|
|
1466
1604
|
draggable: canvasEnabled ? (schema.canvas.draggable ?? true) : false,
|
|
1605
|
+
renderScale: canvasRenderScale,
|
|
1467
1606
|
size: canvasSize ?? defaultCanvasSize,
|
|
1468
1607
|
sizeSource: canvasSize ? ("app" as const) : ("runtime-default" as const),
|
|
1469
1608
|
sizing: canvasSizing,
|