@pixel-point/toolcraft 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +28 -0
- package/templates/runtime/contracts/component-contracts.test.ts +148 -34
- package/templates/runtime/contracts/component-contracts.ts +79 -38
- 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 +474 -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 +38 -7
- 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 +49 -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 +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +1 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +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 +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/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 +3 -1
- 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
|
@@ -65,7 +65,12 @@ import {
|
|
|
65
65
|
toolcraftCanvasAspectRatioPresets,
|
|
66
66
|
} from "../schema/canvas-aspect-ratio-presets";
|
|
67
67
|
import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
getToolcraftCanvasSizeTargetDimension,
|
|
70
|
+
isToolcraftRuntimeOwnedTarget,
|
|
71
|
+
isToolcraftTimelinePanelExtendedTarget,
|
|
72
|
+
isToolcraftTimelinePanelVisibleTarget,
|
|
73
|
+
} from "../schema/runtime-targets";
|
|
69
74
|
import type {
|
|
70
75
|
ToolcraftCommand,
|
|
71
76
|
ToolcraftPanelState,
|
|
@@ -785,7 +790,6 @@ function CanvasAspectRatioControl({
|
|
|
785
790
|
return (
|
|
786
791
|
<div className="min-w-0 space-y-2" data-slot="canvas-aspect-ratio-control">
|
|
787
792
|
<Select
|
|
788
|
-
layout="stacked"
|
|
789
793
|
name={name}
|
|
790
794
|
onValueChange={updatePreset}
|
|
791
795
|
options={canvasAspectRatioOptions}
|
|
@@ -850,6 +854,23 @@ function getActionLabel(action: ToolcraftActionSchema): string {
|
|
|
850
854
|
return action.label ?? action.value;
|
|
851
855
|
}
|
|
852
856
|
|
|
857
|
+
function isExportPanelAction(action: ToolcraftActionSchema): boolean {
|
|
858
|
+
const label = getActionLabel(action);
|
|
859
|
+
const value = action.value;
|
|
860
|
+
|
|
861
|
+
return (
|
|
862
|
+
/\bexport\b/i.test(label) ||
|
|
863
|
+
/(?:^|[._:-])export(?:[._:-]|$)/i.test(value) ||
|
|
864
|
+
/^export(?:[._:-]|$)/i.test(value)
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function getPanelActionIcon(
|
|
869
|
+
action: ToolcraftActionSchema,
|
|
870
|
+
): PanelActionObjectOption["icon"] {
|
|
871
|
+
return isExportPanelAction(action) ? "upload-simple" : action.icon;
|
|
872
|
+
}
|
|
873
|
+
|
|
853
874
|
function getControlName(id: string, label: boolean | string | undefined): string {
|
|
854
875
|
if (typeof label === "string") {
|
|
855
876
|
return label;
|
|
@@ -986,26 +1007,19 @@ function isColorOnlySection(entries: readonly ControlEntry[]): boolean {
|
|
|
986
1007
|
function getRenderedControlsSectionTitle(
|
|
987
1008
|
section: ToolcraftControlSectionSchema,
|
|
988
1009
|
): ToolcraftControlSectionSchema["title"] {
|
|
989
|
-
return
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function shouldHideRuntimeControlsSectionTitle(
|
|
993
|
-
section: ToolcraftControlSectionSchema,
|
|
994
|
-
): boolean {
|
|
995
|
-
return isRuntimeSetupSection(section) || isStickyFooterActionSection(section);
|
|
1010
|
+
return isStickyFooterActionSection(section) ? undefined : section.title;
|
|
996
1011
|
}
|
|
997
1012
|
|
|
998
|
-
function isRuntimeSetupSection(
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
control.target === "canvas.size.height",
|
|
1013
|
+
function isRuntimeSetupSection({
|
|
1014
|
+
entries,
|
|
1015
|
+
section,
|
|
1016
|
+
}: {
|
|
1017
|
+
entries: readonly ControlEntry[];
|
|
1018
|
+
section: ToolcraftControlSectionSchema;
|
|
1019
|
+
}): boolean {
|
|
1020
|
+
return (
|
|
1021
|
+
section.title === "Setup" &&
|
|
1022
|
+
entries.some(([, control]) => isToolcraftRuntimeOwnedTarget(control.target))
|
|
1009
1023
|
);
|
|
1010
1024
|
}
|
|
1011
1025
|
|
|
@@ -1614,6 +1628,14 @@ export function ControlsPanel({
|
|
|
1614
1628
|
function getControlValue(control: ToolcraftControlSchema): unknown {
|
|
1615
1629
|
const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(control.target);
|
|
1616
1630
|
|
|
1631
|
+
if (isToolcraftTimelinePanelExtendedTarget(control.target)) {
|
|
1632
|
+
return state.panels.timeline.extended === true;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (isToolcraftTimelinePanelVisibleTarget(control.target)) {
|
|
1636
|
+
return state.panels.timeline.hidden !== true;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1617
1639
|
return canvasSizeDimension
|
|
1618
1640
|
? state.canvas.size[canvasSizeDimension]
|
|
1619
1641
|
: (state.values[control.target] ?? control.defaultValue);
|
|
@@ -1634,6 +1656,14 @@ export function ControlsPanel({
|
|
|
1634
1656
|
function getTargetValue(target: string): unknown {
|
|
1635
1657
|
const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(target);
|
|
1636
1658
|
|
|
1659
|
+
if (isToolcraftTimelinePanelExtendedTarget(target)) {
|
|
1660
|
+
return state.panels.timeline.extended === true;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (isToolcraftTimelinePanelVisibleTarget(target)) {
|
|
1664
|
+
return state.panels.timeline.hidden !== true;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1637
1667
|
return canvasSizeDimension
|
|
1638
1668
|
? state.canvas.size[canvasSizeDimension]
|
|
1639
1669
|
: (state.values[target] ?? getControlDefaultValueByTarget(target));
|
|
@@ -2371,14 +2401,17 @@ export function ControlsPanel({
|
|
|
2371
2401
|
{visibleSections.map(({ entries, section }, sectionIndex) => {
|
|
2372
2402
|
const visibleControls = getControlsRecord(entries);
|
|
2373
2403
|
const sectionHasOnlyColorFields = isColorOnlySection(entries);
|
|
2374
|
-
const
|
|
2375
|
-
const
|
|
2404
|
+
const isRuntimeSetup = isRuntimeSetupSection({ entries, section });
|
|
2405
|
+
const renderedSectionTitle = isRuntimeSetup
|
|
2406
|
+
? undefined
|
|
2407
|
+
: getRenderedControlsSectionTitle(section);
|
|
2408
|
+
const sectionSpacing = "default";
|
|
2376
2409
|
const sectionCollapseKey = getControlsPanelSectionCollapseKey({
|
|
2377
2410
|
entries,
|
|
2378
2411
|
section,
|
|
2379
2412
|
sectionIndex,
|
|
2380
2413
|
});
|
|
2381
|
-
const isSectionCollapsible = renderedSectionTitle !== undefined;
|
|
2414
|
+
const isSectionCollapsible = !isRuntimeSetup && renderedSectionTitle !== undefined;
|
|
2382
2415
|
const isSectionCollapsed =
|
|
2383
2416
|
isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
|
|
2384
2417
|
const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
|
|
@@ -2677,6 +2710,7 @@ export function ControlsPanel({
|
|
|
2677
2710
|
id: asset.id,
|
|
2678
2711
|
size: asset.size,
|
|
2679
2712
|
src: asset.dataUrl,
|
|
2713
|
+
transform: asset.transform,
|
|
2680
2714
|
}));
|
|
2681
2715
|
const previewMediaIds = previewMediaAssets.map((asset) => asset.id);
|
|
2682
2716
|
const importFile = (file: File, replaceExisting: boolean): void => {
|
|
@@ -2786,6 +2820,17 @@ export function ControlsPanel({
|
|
|
2786
2820
|
type: "media.reorder",
|
|
2787
2821
|
});
|
|
2788
2822
|
}}
|
|
2823
|
+
onPreviewTransform={(item, operation) => {
|
|
2824
|
+
if (!item.id) {
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
dispatchCommand({
|
|
2829
|
+
mediaId: item.id,
|
|
2830
|
+
operation,
|
|
2831
|
+
type: "media.transform",
|
|
2832
|
+
});
|
|
2833
|
+
}}
|
|
2789
2834
|
preview={
|
|
2790
2835
|
previewMediaAsset
|
|
2791
2836
|
? {
|
|
@@ -2793,6 +2838,7 @@ export function ControlsPanel({
|
|
|
2793
2838
|
alt: previewMediaAsset.fileName,
|
|
2794
2839
|
size: previewMediaAsset.size,
|
|
2795
2840
|
src: previewMediaAsset.dataUrl,
|
|
2841
|
+
transform: previewMediaAsset.transform,
|
|
2796
2842
|
}
|
|
2797
2843
|
: undefined
|
|
2798
2844
|
}
|
|
@@ -2867,7 +2913,7 @@ export function ControlsPanel({
|
|
|
2867
2913
|
: undefined
|
|
2868
2914
|
}
|
|
2869
2915
|
key={id}
|
|
2870
|
-
|
|
2916
|
+
onValueChange={commit}
|
|
2871
2917
|
value={
|
|
2872
2918
|
isRecord(value)
|
|
2873
2919
|
? (value as React.ComponentProps<typeof Palette>["value"])
|
|
@@ -2888,7 +2934,7 @@ export function ControlsPanel({
|
|
|
2888
2934
|
return (
|
|
2889
2935
|
<PanelActions
|
|
2890
2936
|
actions={actions.map((action) => ({
|
|
2891
|
-
icon: action
|
|
2937
|
+
icon: getPanelActionIcon(action),
|
|
2892
2938
|
name: getActionLabel(action),
|
|
2893
2939
|
value: action.value,
|
|
2894
2940
|
variant: getPanelActionButtonVariant(action.variant),
|
|
@@ -2979,7 +3025,6 @@ export function ControlsPanel({
|
|
|
2979
3025
|
return (
|
|
2980
3026
|
<Select
|
|
2981
3027
|
key={id}
|
|
2982
|
-
layout={isInInlineLayoutGroup ? "stacked" : undefined}
|
|
2983
3028
|
name={name}
|
|
2984
3029
|
onValueChange={commit}
|
|
2985
3030
|
options={control.options ?? []}
|
|
@@ -48,7 +48,7 @@ describe("Toolcraft template runtime public API", () => {
|
|
|
48
48
|
>().toEqualTypeOf<ControlsPanelKeys>();
|
|
49
49
|
expectTypeOf<
|
|
50
50
|
keyof React.ComponentProps<typeof TimelinePanel>
|
|
51
|
-
>().toEqualTypeOf<RuntimePanelKeys | "defaultExpanded">();
|
|
51
|
+
>().toEqualTypeOf<RuntimePanelKeys | "defaultExpanded" | "variant">();
|
|
52
52
|
expectTypeOf<
|
|
53
53
|
keyof React.ComponentProps<typeof ToolbarPanel>
|
|
54
54
|
>().toEqualTypeOf<RuntimePanelKeys>();
|
|
@@ -64,6 +64,8 @@ describe("settings transfer", () => {
|
|
|
64
64
|
const state = createToolcraftState(schema, {
|
|
65
65
|
values: {
|
|
66
66
|
"generation.prompt": "Exported prompt",
|
|
67
|
+
"panels.timeline.extended": true,
|
|
68
|
+
"panels.timeline.visible": false,
|
|
67
69
|
"style.opacity": 24,
|
|
68
70
|
},
|
|
69
71
|
});
|
|
@@ -82,6 +84,8 @@ describe("settings transfer", () => {
|
|
|
82
84
|
},
|
|
83
85
|
version: 1,
|
|
84
86
|
});
|
|
87
|
+
expect(payload.values).not.toHaveProperty("panels.timeline.extended");
|
|
88
|
+
expect(payload.values).not.toHaveProperty("panels.timeline.visible");
|
|
85
89
|
expect(parseToolcraftSettingsPayload(schema, payload)).toEqual(payload);
|
|
86
90
|
expect(
|
|
87
91
|
parseToolcraftSettingsPayload(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedToolcraftAppSchema } from "../schema/types";
|
|
2
|
+
import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
|
|
2
3
|
import type {
|
|
3
4
|
ToolcraftCommand,
|
|
4
5
|
ToolcraftState,
|
|
@@ -50,7 +51,11 @@ function getKnownValueTargets(schema: ResolvedToolcraftAppSchema): Set<string> {
|
|
|
50
51
|
|
|
51
52
|
for (const section of schema.panels.controls?.sections ?? []) {
|
|
52
53
|
for (const control of Object.values(section.controls)) {
|
|
53
|
-
if (
|
|
54
|
+
if (
|
|
55
|
+
control.type !== "panelActions" &&
|
|
56
|
+
control.type !== "settingsTransfer" &&
|
|
57
|
+
!isToolcraftTimelinePanelRuntimeTarget(control.target)
|
|
58
|
+
) {
|
|
54
59
|
targets.add(control.target);
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -97,6 +97,20 @@ describe("TimelinePanel", () => {
|
|
|
97
97
|
expect(screen.queryByText("Add your first keyframe from the properties panel.")).toBeNull();
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
it("renders compact presentation as playback-only transport", () => {
|
|
101
|
+
renderTimelinePanel({ variant: "compact" });
|
|
102
|
+
|
|
103
|
+
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
|
|
104
|
+
expect(screen.queryByRole("button", { name: "Disable loop" })).toBeNull();
|
|
105
|
+
expect(screen.queryByText("Dur:")).toBeNull();
|
|
106
|
+
expect(screen.queryByText("Duration:")).toBeNull();
|
|
107
|
+
expect(screen.queryByRole("slider", { name: "Playback position" })).toBeNull();
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "Pause playback" }));
|
|
110
|
+
|
|
111
|
+
expect(screen.getByTestId("timeline-playing").textContent).toBe("false");
|
|
112
|
+
});
|
|
113
|
+
|
|
100
114
|
it("renders timeline state and dispatches playback commands", () => {
|
|
101
115
|
renderTimelinePanel();
|
|
102
116
|
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
ToolcraftTimelineKeyframeEasing,
|
|
32
32
|
ToolcraftTimelineKeyframeGroup,
|
|
33
33
|
} from '../state/types';
|
|
34
|
+
import { getToolcraftTimelineLoopTime } from '../state/timeline-loop';
|
|
34
35
|
import { isTimelineReadyForPlayback } from '../state/timeline-readiness';
|
|
35
36
|
import { PanelContainer } from './panel-host';
|
|
36
37
|
import type { PanelPlacement, PanelStateChange } from './panel-host-types';
|
|
@@ -43,6 +44,7 @@ type TimelinePanelProps = {
|
|
|
43
44
|
onPanelStateChange?: PanelStateChange;
|
|
44
45
|
panelPlacement?: PanelPlacement;
|
|
45
46
|
panelState?: ToolcraftPanelState;
|
|
47
|
+
variant?: 'compact' | 'extended';
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
function cn(...classNames: Array<string | false | null | undefined>): string {
|
|
@@ -50,6 +52,7 @@ function cn(...classNames: Array<string | false | null | undefined>): string {
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
const timelinePanelCollapsedSize = { height: 36 } as const;
|
|
55
|
+
const timelinePanelCompactWidthPx = 36;
|
|
53
56
|
const timelinePanelCollapsedWidthPx = 256;
|
|
54
57
|
const timelinePanelExpandedWidthPx = 688;
|
|
55
58
|
const timelinePanelMinResponsiveWidthPx = 320;
|
|
@@ -2336,12 +2339,15 @@ function useTimelineClock({
|
|
|
2336
2339
|
setCurrentTimeSeconds((currentValue) => {
|
|
2337
2340
|
const nextValue = currentValue + elapsedSeconds;
|
|
2338
2341
|
|
|
2339
|
-
if (nextValue
|
|
2342
|
+
if (nextValue < durationSeconds) {
|
|
2340
2343
|
return nextValue;
|
|
2341
2344
|
}
|
|
2342
2345
|
|
|
2343
2346
|
if (isLooping) {
|
|
2344
|
-
return
|
|
2347
|
+
return getToolcraftTimelineLoopTime({
|
|
2348
|
+
currentTimeSeconds: nextValue,
|
|
2349
|
+
durationSeconds,
|
|
2350
|
+
});
|
|
2345
2351
|
}
|
|
2346
2352
|
|
|
2347
2353
|
setIsPlaying(false);
|
|
@@ -2608,6 +2614,7 @@ function TimelinePanelHeader({
|
|
|
2608
2614
|
onToggleLoop,
|
|
2609
2615
|
onTogglePlayback,
|
|
2610
2616
|
stripRef,
|
|
2617
|
+
variant,
|
|
2611
2618
|
}: {
|
|
2612
2619
|
canExpand: boolean;
|
|
2613
2620
|
currentTimeSeconds: number;
|
|
@@ -2626,7 +2633,31 @@ function TimelinePanelHeader({
|
|
|
2626
2633
|
onToggleLoop: () => void;
|
|
2627
2634
|
onTogglePlayback: () => void;
|
|
2628
2635
|
stripRef: React.RefObject<HTMLDivElement | null>;
|
|
2636
|
+
variant: 'compact' | 'extended';
|
|
2629
2637
|
}): React.JSX.Element {
|
|
2638
|
+
if (variant === 'compact') {
|
|
2639
|
+
return (
|
|
2640
|
+
<div
|
|
2641
|
+
className="relative flex h-full min-w-0 shrink-0 items-center justify-center"
|
|
2642
|
+
data-slot="timeline-panel-header"
|
|
2643
|
+
data-timeline-panel-variant="compact"
|
|
2644
|
+
>
|
|
2645
|
+
<div
|
|
2646
|
+
className="relative z-10 inline-flex shrink-0 items-center"
|
|
2647
|
+
data-slot="timeline-transport-controls"
|
|
2648
|
+
>
|
|
2649
|
+
<TimelineIconButton
|
|
2650
|
+
disabled={!playbackReady}
|
|
2651
|
+
label={isPlaying ? 'Pause playback' : 'Play playback'}
|
|
2652
|
+
onClick={onTogglePlayback}
|
|
2653
|
+
>
|
|
2654
|
+
{isPlaying ? <Pause /> : <Play />}
|
|
2655
|
+
</TimelineIconButton>
|
|
2656
|
+
</div>
|
|
2657
|
+
</div>
|
|
2658
|
+
);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2630
2661
|
return (
|
|
2631
2662
|
<div
|
|
2632
2663
|
className={cn(
|
|
@@ -2705,6 +2736,7 @@ export function TimelinePanel({
|
|
|
2705
2736
|
onPanelStateChange,
|
|
2706
2737
|
panelPlacement,
|
|
2707
2738
|
panelState,
|
|
2739
|
+
variant = 'extended',
|
|
2708
2740
|
}: TimelinePanelProps): React.JSX.Element | null {
|
|
2709
2741
|
const { dispatch, state } = useToolcraft();
|
|
2710
2742
|
|
|
@@ -2727,7 +2759,8 @@ export function TimelinePanel({
|
|
|
2727
2759
|
const [defaultExpandedPending, setDefaultExpandedPending] = useState(defaultExpanded);
|
|
2728
2760
|
const [isHoverPaused, setIsHoverPaused] = useState(false);
|
|
2729
2761
|
const displayedIsPlaying = playbackReady && isPlaying;
|
|
2730
|
-
const
|
|
2762
|
+
const isCompact = variant === 'compact';
|
|
2763
|
+
const isExpanded = !isCompact && keyframesEnabled && (expanded || defaultExpandedPending);
|
|
2731
2764
|
const expandedPanelSize = getTimelinePanelExpandedSize(keyframeGroups);
|
|
2732
2765
|
const previousIsExpandedRef = useRef(isExpanded);
|
|
2733
2766
|
const timelineRef = useRef(state.timeline);
|
|
@@ -2911,7 +2944,9 @@ export function TimelinePanel({
|
|
|
2911
2944
|
const { panelRef: timelineSurfaceRef, responsiveLayout } = useTimelinePanelResponsiveLayout(
|
|
2912
2945
|
isExpanded && !shouldConstrainToContainer,
|
|
2913
2946
|
);
|
|
2914
|
-
const unconstrainedTimelinePanelWidth =
|
|
2947
|
+
const unconstrainedTimelinePanelWidth = isCompact
|
|
2948
|
+
? timelinePanelCompactWidthPx
|
|
2949
|
+
: isExpanded
|
|
2915
2950
|
? expandedPanelSize.width
|
|
2916
2951
|
: timelinePanelCollapsedWidthPx;
|
|
2917
2952
|
const timelinePanelWidth =
|
|
@@ -2947,6 +2982,7 @@ export function TimelinePanel({
|
|
|
2947
2982
|
data-playback-ready={playbackReady ? 'true' : 'false'}
|
|
2948
2983
|
data-scrubbing={scrubber.isScrubbing ? 'true' : 'false'}
|
|
2949
2984
|
data-slot="timeline-panel"
|
|
2985
|
+
data-timeline-panel-variant={variant}
|
|
2950
2986
|
initial={false}
|
|
2951
2987
|
ref={timelineSurfaceRef}
|
|
2952
2988
|
style={timelinePanelLayoutStyle}
|
|
@@ -2956,7 +2992,7 @@ export function TimelinePanel({
|
|
|
2956
2992
|
className={cn(
|
|
2957
2993
|
'group/timeline-panel-surface relative flex h-full w-full flex-col rounded-t-lg rounded-b-lg',
|
|
2958
2994
|
isExpanded ? 'overflow-hidden' : 'overflow-visible p-1',
|
|
2959
|
-
!isExpanded && !keyframesEnabled && 'pr-3',
|
|
2995
|
+
!isCompact && !isExpanded && !keyframesEnabled && 'pr-3',
|
|
2960
2996
|
)}
|
|
2961
2997
|
data-panel-id="timeline"
|
|
2962
2998
|
onPointerEnter={() => setIsHoverPaused(true)}
|
|
@@ -2970,13 +3006,13 @@ export function TimelinePanel({
|
|
|
2970
3006
|
setIsHoverPaused(false);
|
|
2971
3007
|
}}
|
|
2972
3008
|
>
|
|
2973
|
-
{isExpanded ?
|
|
3009
|
+
{!isCompact && !isExpanded ? (
|
|
2974
3010
|
<TimelinePanelMask
|
|
2975
3011
|
currentTimeSeconds={currentTimeSeconds}
|
|
2976
3012
|
durationSeconds={durationSeconds}
|
|
2977
3013
|
isHandleVisible={isHoverPaused || scrubber.isScrubbing}
|
|
2978
3014
|
/>
|
|
2979
|
-
)}
|
|
3015
|
+
) : null}
|
|
2980
3016
|
<TimelinePanelHeader
|
|
2981
3017
|
canExpand={keyframesEnabled}
|
|
2982
3018
|
currentTimeSeconds={currentTimeSeconds}
|
|
@@ -3001,6 +3037,7 @@ export function TimelinePanel({
|
|
|
3001
3037
|
dispatch({ type: 'timeline.togglePlayback' });
|
|
3002
3038
|
}}
|
|
3003
3039
|
stripRef={scrubber.stripRef}
|
|
3040
|
+
variant={variant}
|
|
3004
3041
|
/>
|
|
3005
3042
|
{isExpanded && keyframesEnabled ? (
|
|
3006
3043
|
<TimelineExpandedContent
|
|
@@ -188,6 +188,10 @@ describe("assembled Toolcraft template runtime", () => {
|
|
|
188
188
|
renderAssembledEditor();
|
|
189
189
|
|
|
190
190
|
expect(screen.getByText("Layers")).toBeTruthy();
|
|
191
|
+
const timelineField = screen.getByText("Timeline").closest('[role="group"]');
|
|
192
|
+
const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
|
|
193
|
+
|
|
194
|
+
fireEvent.click(timelineSwitch as HTMLElement);
|
|
191
195
|
fireEvent.click(
|
|
192
196
|
screen.getByRole("button", { name: "Expand timeline panel" }),
|
|
193
197
|
);
|
|
@@ -266,7 +270,8 @@ describe("assembled Toolcraft template runtime", () => {
|
|
|
266
270
|
).toBe("320px");
|
|
267
271
|
expect(screen.getByText("Master Controls")).toBeTruthy();
|
|
268
272
|
expect(screen.getByText("Layers")).toBeTruthy();
|
|
269
|
-
expect(screen.
|
|
273
|
+
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
|
|
274
|
+
expect(screen.queryByText("Dur:")).toBeNull();
|
|
270
275
|
expect(screen.queryByText("Duration:")).toBeNull();
|
|
271
276
|
expect(toolbar?.textContent).toContain("100%");
|
|
272
277
|
expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled();
|
|
@@ -339,6 +344,9 @@ describe("assembled Toolcraft template runtime", () => {
|
|
|
339
344
|
});
|
|
340
345
|
expect(screen.getByRole("button", { name: "Undo" })).not.toBeDisabled();
|
|
341
346
|
|
|
347
|
+
const timelineField = screen.getByText("Timeline").closest('[role="group"]');
|
|
348
|
+
const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
|
|
349
|
+
fireEvent.click(timelineSwitch as HTMLElement);
|
|
342
350
|
fireEvent.click(
|
|
343
351
|
screen.getByRole("button", { name: "Expand timeline panel" }),
|
|
344
352
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
act,
|
|
2
3
|
cleanup,
|
|
3
4
|
fireEvent,
|
|
4
5
|
render,
|
|
@@ -6,7 +7,7 @@ import {
|
|
|
6
7
|
waitFor,
|
|
7
8
|
} from "@testing-library/react";
|
|
8
9
|
import * as React from "react";
|
|
9
|
-
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
10
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
10
11
|
|
|
11
12
|
import { defineToolcraft } from "../schema/define-toolcraft";
|
|
12
13
|
import type { ToolcraftCustomControlRendererProps } from "./control-renderers";
|
|
@@ -27,9 +28,17 @@ beforeAll(() => {
|
|
|
27
28
|
}),
|
|
28
29
|
writable: true,
|
|
29
30
|
});
|
|
31
|
+
window.requestAnimationFrame ??= ((callback: FrameRequestCallback) =>
|
|
32
|
+
window.setTimeout(
|
|
33
|
+
() => callback(performance.now()),
|
|
34
|
+
16,
|
|
35
|
+
)) as typeof window.requestAnimationFrame;
|
|
36
|
+
window.cancelAnimationFrame ??= ((handle: number) =>
|
|
37
|
+
window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
|
|
30
38
|
});
|
|
31
39
|
|
|
32
40
|
afterEach(() => {
|
|
41
|
+
vi.useRealTimers();
|
|
33
42
|
cleanup();
|
|
34
43
|
document.documentElement.classList.remove("dark");
|
|
35
44
|
document.documentElement.style.colorScheme = "";
|
|
@@ -113,6 +122,16 @@ function CustomValueProbe(): React.JSX.Element {
|
|
|
113
122
|
);
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
function TimelineTimeProbe(): React.JSX.Element {
|
|
126
|
+
const { state } = useToolcraft();
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<span data-testid="timeline-time">
|
|
130
|
+
{state.timeline.currentTimeSeconds.toFixed(3)}
|
|
131
|
+
</span>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
describe("ToolcraftApp", () => {
|
|
117
136
|
it("renders enabled runtime surfaces from the app schema", () => {
|
|
118
137
|
const { container } = render(<ToolcraftApp schema={createSchema()} />);
|
|
@@ -122,7 +141,11 @@ describe("ToolcraftApp", () => {
|
|
|
122
141
|
).toBeTruthy();
|
|
123
142
|
expect(screen.getByText("Controls")).toBeTruthy();
|
|
124
143
|
expect(screen.getByText("Layers")).toBeTruthy();
|
|
125
|
-
expect(screen.
|
|
144
|
+
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
|
|
145
|
+
expect(
|
|
146
|
+
container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
|
|
147
|
+
).toBeTruthy();
|
|
148
|
+
expect(screen.queryByText("Dur:")).toBeNull();
|
|
126
149
|
expect(screen.queryByText("Duration:")).toBeNull();
|
|
127
150
|
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
|
|
128
151
|
expect(screen.getByRole("button", { name: "Redo" })).toBeTruthy();
|
|
@@ -145,6 +168,92 @@ describe("ToolcraftApp", () => {
|
|
|
145
168
|
expect(container.querySelector('[data-panel-type="toolbar"]')).toBeTruthy();
|
|
146
169
|
});
|
|
147
170
|
|
|
171
|
+
it("switches the timeline panel between compact and extended through the runtime setup toggle", () => {
|
|
172
|
+
const { container } = render(<ToolcraftApp schema={createSchema()} />);
|
|
173
|
+
|
|
174
|
+
expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
|
|
175
|
+
expect(
|
|
176
|
+
container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
|
|
177
|
+
).toBeTruthy();
|
|
178
|
+
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
|
|
179
|
+
expect(screen.queryByText("Dur:")).toBeNull();
|
|
180
|
+
|
|
181
|
+
const timelineField = screen.getByText("Timeline").closest('[role="group"]');
|
|
182
|
+
const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
|
|
183
|
+
|
|
184
|
+
expect(timelineSwitch?.getAttribute("aria-checked")).toBe("false");
|
|
185
|
+
|
|
186
|
+
fireEvent.click(timelineSwitch as HTMLElement);
|
|
187
|
+
|
|
188
|
+
expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
|
|
189
|
+
expect(
|
|
190
|
+
container.querySelector('[data-toolcraft-timeline-panel-variant="extended"]'),
|
|
191
|
+
).toBeTruthy();
|
|
192
|
+
expect(container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]')).toBeNull();
|
|
193
|
+
expect(screen.getByText("Dur:")).toBeTruthy();
|
|
194
|
+
expect(screen.getByText(/\d+(?:\.\d+)? \/ 8s/)).toBeTruthy();
|
|
195
|
+
expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps timeline playback running while the panel is hidden", () => {
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
const schema = defineToolcraft({
|
|
201
|
+
canvas: {
|
|
202
|
+
enabled: true,
|
|
203
|
+
size: { height: 180, unit: "px", width: 320 },
|
|
204
|
+
},
|
|
205
|
+
panels: {
|
|
206
|
+
controls: {
|
|
207
|
+
sections: [],
|
|
208
|
+
title: "Controls",
|
|
209
|
+
},
|
|
210
|
+
timeline: { mode: "playback" },
|
|
211
|
+
},
|
|
212
|
+
persistence: {
|
|
213
|
+
include: ["panels", "timeline"],
|
|
214
|
+
key: "toolcraft:timeline-hidden-test:state:v1",
|
|
215
|
+
storage: "localStorage",
|
|
216
|
+
version: 1,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
window.localStorage.setItem(
|
|
221
|
+
"toolcraft:timeline-hidden-test:state:v1",
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
state: {
|
|
224
|
+
panels: {
|
|
225
|
+
timeline: { hidden: true },
|
|
226
|
+
},
|
|
227
|
+
timeline: {
|
|
228
|
+
currentTimeSeconds: 0,
|
|
229
|
+
durationSeconds: 8,
|
|
230
|
+
expanded: false,
|
|
231
|
+
isLooping: true,
|
|
232
|
+
isPlaying: true,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
version: 1,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const { container } = render(
|
|
240
|
+
<ToolcraftApp canvasContent={<TimelineTimeProbe />} schema={schema} />,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(
|
|
244
|
+
container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]'),
|
|
245
|
+
).toBeTruthy();
|
|
246
|
+
expect(screen.getByTestId("timeline-time").textContent).toBe("0.000");
|
|
247
|
+
|
|
248
|
+
act(() => {
|
|
249
|
+
vi.advanceTimersByTime(200);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(Number(screen.getByTestId("timeline-time").textContent)).toBeGreaterThan(0);
|
|
253
|
+
|
|
254
|
+
vi.useRealTimers();
|
|
255
|
+
});
|
|
256
|
+
|
|
148
257
|
it("does not render disabled optional panels or toolbar features", () => {
|
|
149
258
|
const schema = defineToolcraft({
|
|
150
259
|
canvas: { enabled: true },
|
|
@@ -272,7 +381,7 @@ describe("ToolcraftApp", () => {
|
|
|
272
381
|
footer: {
|
|
273
382
|
actions: [
|
|
274
383
|
{
|
|
275
|
-
icon: "
|
|
384
|
+
icon: "upload-simple",
|
|
276
385
|
label: "Export image",
|
|
277
386
|
value: "export",
|
|
278
387
|
variant: "default",
|