@hyperframes/studio 0.7.76 → 0.7.77
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/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BLICKger.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-Du1RoLiB.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.77",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/core": "0.7.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/core": "0.7.77",
|
|
50
|
+
"@hyperframes/parsers": "0.7.77",
|
|
51
|
+
"@hyperframes/player": "0.7.77",
|
|
52
|
+
"@hyperframes/sdk": "0.7.77",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.77"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.77"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
|
|
2
2
|
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
|
3
3
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
4
|
-
import { usePlayerStore
|
|
4
|
+
import { usePlayerStore } from "./player";
|
|
5
5
|
import { StudioOverlays } from "./components/StudioOverlays";
|
|
6
6
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
7
7
|
import { useCaptionStore } from "./captions/store";
|
|
@@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
|
|
|
12
12
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
13
13
|
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
|
|
14
14
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
persistTimelineMoveEditsAtomically,
|
|
17
|
+
type TimelineMoveEditsHandler,
|
|
18
|
+
type TimelineMoveOperation,
|
|
19
|
+
} from "./hooks/timelineMoveAdapter";
|
|
16
20
|
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
|
17
|
-
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
|
|
18
21
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
19
22
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
20
23
|
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
|
@@ -62,7 +65,6 @@ import {
|
|
|
62
65
|
} from "./utils/studioUrlState";
|
|
63
66
|
import { trackStudioSessionStart } from "./telemetry/events";
|
|
64
67
|
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
65
|
-
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
|
|
66
68
|
// fallow-ignore-next-line complexity
|
|
67
69
|
export function StudioApp() {
|
|
68
70
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
@@ -154,6 +156,10 @@ export function StudioApp() {
|
|
|
154
156
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
|
155
157
|
pendingTimelineEditPathRef,
|
|
156
158
|
});
|
|
159
|
+
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
160
|
+
// Stable identity — what the ref indirection is for. An inline arrow re-created
|
|
161
|
+
// the memoized timeline handlers (it is in their deps) on every render.
|
|
162
|
+
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
|
|
157
163
|
const timelineEditing = useTimelineEditing({
|
|
158
164
|
projectId,
|
|
159
165
|
activeCompPath,
|
|
@@ -171,20 +177,11 @@ export function StudioApp() {
|
|
|
171
177
|
sdkSession: editFlowSdkSession,
|
|
172
178
|
publishSdkSession: sdkHandle.publish,
|
|
173
179
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
180
|
+
invalidateGsapCache,
|
|
174
181
|
handleDomZIndexReorderCommitRef,
|
|
175
182
|
});
|
|
176
|
-
const handleTimelineElementsMove = useCallback(
|
|
177
|
-
async (
|
|
178
|
-
edits: Array<{
|
|
179
|
-
element: TimelineElement;
|
|
180
|
-
updates: Pick<TimelineElement, "start" | "track"> & {
|
|
181
|
-
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
182
|
-
};
|
|
183
|
-
}>,
|
|
184
|
-
coalesceKey?: string,
|
|
185
|
-
operation: TimelineMoveOperation = "timing",
|
|
186
|
-
coalesceMs?: number,
|
|
187
|
-
) => {
|
|
183
|
+
const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
|
|
184
|
+
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
|
|
188
185
|
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
|
|
189
186
|
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
|
|
190
187
|
},
|
|
@@ -228,7 +225,6 @@ export function StudioApp() {
|
|
|
228
225
|
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
|
|
229
226
|
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
|
230
227
|
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
|
231
|
-
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
232
228
|
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
|
233
229
|
projectId,
|
|
234
230
|
activeCompPath,
|
|
@@ -402,12 +398,14 @@ export function StudioApp() {
|
|
|
402
398
|
designPanelActive,
|
|
403
399
|
inspectorPanelActive,
|
|
404
400
|
inspectorButtonActive,
|
|
401
|
+
shouldShowMotionPath,
|
|
405
402
|
shouldShowSelectedDomBounds,
|
|
406
403
|
} = useInspectorState(
|
|
407
404
|
panelLayout.rightPanelTab,
|
|
408
405
|
panelLayout.rightInspectorPanes,
|
|
409
406
|
panelLayout.rightCollapsed,
|
|
410
407
|
isPlaying,
|
|
408
|
+
domEditSession.domEditSelection,
|
|
411
409
|
gestureState === "recording",
|
|
412
410
|
);
|
|
413
411
|
useStudioUrlState({
|
|
@@ -558,6 +556,7 @@ export function StudioApp() {
|
|
|
558
556
|
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
559
557
|
setCompIdToSrc={setCompIdToSrc}
|
|
560
558
|
setCompositionLoading={setCompositionLoading}
|
|
559
|
+
shouldShowMotionPath={shouldShowMotionPath}
|
|
561
560
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
562
561
|
isGestureRecording={gestureState === "recording"}
|
|
563
562
|
recordingState={gestureState}
|
|
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
|
56
56
|
) => Promise<void> | void;
|
|
57
57
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
58
58
|
setCompositionLoading: (loading: boolean) => void;
|
|
59
|
+
shouldShowMotionPath: boolean;
|
|
59
60
|
shouldShowSelectedDomBounds: boolean;
|
|
60
61
|
blockPreview?: BlockPreviewInfo | null;
|
|
61
62
|
isGestureRecording?: boolean;
|
|
@@ -90,6 +91,7 @@ export function EditorShell({
|
|
|
90
91
|
handleRazorSplitAll,
|
|
91
92
|
setCompIdToSrc,
|
|
92
93
|
setCompositionLoading,
|
|
94
|
+
shouldShowMotionPath,
|
|
93
95
|
shouldShowSelectedDomBounds,
|
|
94
96
|
isGestureRecording,
|
|
95
97
|
recordingState,
|
|
@@ -149,6 +151,7 @@ export function EditorShell({
|
|
|
149
151
|
onDeleteElement={handleTimelineElementDelete}
|
|
150
152
|
previewOverlay={
|
|
151
153
|
<PreviewOverlays
|
|
154
|
+
shouldShowMotionPath={shouldShowMotionPath}
|
|
152
155
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
153
156
|
blockPreview={blockPreview}
|
|
154
157
|
isGestureRecording={isGestureRecording}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
7
9
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
10
|
|
|
9
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -13,12 +15,14 @@ afterEach(() => {
|
|
|
13
15
|
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
14
16
|
});
|
|
15
17
|
|
|
16
|
-
function renderToolbar(
|
|
18
|
+
function renderToolbar(
|
|
19
|
+
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
|
|
20
|
+
) {
|
|
17
21
|
const host = document.createElement("div");
|
|
18
22
|
document.body.append(host);
|
|
19
23
|
const root = createRoot(host);
|
|
20
24
|
act(() => {
|
|
21
|
-
root.render(<TimelineToolbar />);
|
|
25
|
+
root.render(<TimelineToolbar domEditSession={domEditSession} />);
|
|
22
26
|
});
|
|
23
27
|
return { host, root };
|
|
24
28
|
}
|
|
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
|
|
54
58
|
act(() => root.unmount());
|
|
55
59
|
});
|
|
56
60
|
});
|
|
61
|
+
describe("TimelineToolbar — motion path endpoints", () => {
|
|
62
|
+
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
|
|
63
|
+
usePlayerStore.setState({ currentTime: 10 });
|
|
64
|
+
const animation: GsapAnimation = {
|
|
65
|
+
id: "#el-to-0-position",
|
|
66
|
+
targetSelector: "#el",
|
|
67
|
+
method: "to",
|
|
68
|
+
position: 0,
|
|
69
|
+
duration: 10,
|
|
70
|
+
properties: {},
|
|
71
|
+
keyframes: {
|
|
72
|
+
format: "object-array",
|
|
73
|
+
keyframes: [
|
|
74
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
75
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
arcPath: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
autoRotate: false,
|
|
81
|
+
segments: [{ curviness: 1 }],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const element = document.createElement("div");
|
|
85
|
+
element.id = "el";
|
|
86
|
+
const session = {
|
|
87
|
+
domEditSelection: makeSelection("Element", element),
|
|
88
|
+
selectedGsapAnimations: [animation],
|
|
89
|
+
handleGsapAddAnimation: vi.fn(),
|
|
90
|
+
handleGsapConvertToKeyframes: vi.fn(),
|
|
91
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
92
|
+
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
|
|
93
|
+
|
|
94
|
+
const { host, root } = renderToolbar(session);
|
|
95
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
96
|
+
'button[aria-label="Motion path endpoint"]',
|
|
97
|
+
);
|
|
98
|
+
expect(button?.disabled).toBe(true);
|
|
99
|
+
act(() => root.unmount());
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
isPlayheadWithinTween,
|
|
6
6
|
type EnableKeyframesSession,
|
|
7
7
|
} from "../hooks/useEnableKeyframes";
|
|
8
|
-
import { computeElementPercentage } from "../hooks/gsapShared";
|
|
8
|
+
import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
|
|
9
9
|
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
|
10
10
|
import {
|
|
11
11
|
getNextTimelineZoomPercent,
|
|
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
|
|
|
36
36
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface KeyframeToggleState {
|
|
40
|
+
state: "active" | "inactive" | "none";
|
|
41
|
+
isMotionPath: boolean;
|
|
42
|
+
pathEndpoint: boolean;
|
|
43
|
+
willExtend: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
|
|
47
|
+
state: "none",
|
|
48
|
+
isMotionPath: false,
|
|
49
|
+
pathEndpoint: false,
|
|
50
|
+
willExtend: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
|
|
54
|
+
if (!animation?.keyframes) return false;
|
|
55
|
+
const keyframes = animation.keyframes.keyframes;
|
|
56
|
+
return (
|
|
57
|
+
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
|
|
58
|
+
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveKeyframeToggleState(
|
|
63
|
+
session: DomEditSessionSlice | undefined,
|
|
64
|
+
currentTime: number,
|
|
65
|
+
): KeyframeToggleState {
|
|
66
|
+
if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
|
|
67
|
+
const arcAnimation = session.selectedGsapAnimations.find(
|
|
68
|
+
(animation) => animation.arcPath && animation.keyframes,
|
|
69
|
+
);
|
|
70
|
+
const animation =
|
|
71
|
+
arcAnimation ??
|
|
72
|
+
session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
|
|
73
|
+
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
|
|
74
|
+
|
|
75
|
+
const isMotionPath = Boolean(arcAnimation);
|
|
76
|
+
if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
|
|
77
|
+
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
|
|
81
|
+
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
|
|
82
|
+
const active = animation.keyframes.keyframes.some(
|
|
83
|
+
(keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
state: pathEndpoint ? "none" : active ? "active" : "inactive",
|
|
87
|
+
isMotionPath,
|
|
88
|
+
pathEndpoint,
|
|
89
|
+
willExtend: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
39
93
|
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
40
94
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
41
95
|
const sessionRef = useRef(session);
|
|
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
45
99
|
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
|
|
46
100
|
);
|
|
47
101
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const sel = session.domEditSelection;
|
|
51
|
-
const anims = session.selectedGsapAnimations;
|
|
52
|
-
const kfAnim = anims.find((a) => a.keyframes);
|
|
53
|
-
|
|
54
|
-
let state: "active" | "inactive" | "none" = "none";
|
|
55
|
-
// Outside the tween, clicking extends the animation to the playhead rather than
|
|
56
|
-
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
|
|
57
|
-
let willExtend = false;
|
|
58
|
-
if (kfAnim?.keyframes && sel) {
|
|
59
|
-
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
|
|
60
|
-
state = "inactive";
|
|
61
|
-
willExtend = true;
|
|
62
|
-
} else {
|
|
63
|
-
// Tween-relative percentage (not the clip range) so the button state matches
|
|
64
|
-
// where the keyframe would actually land.
|
|
65
|
-
const pct = computeElementPercentage(currentTime, sel, kfAnim);
|
|
66
|
-
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
|
67
|
-
? "active"
|
|
68
|
-
: "inactive";
|
|
69
|
-
}
|
|
70
|
-
}
|
|
102
|
+
const toggleState = resolveKeyframeToggleState(session, currentTime);
|
|
71
103
|
|
|
72
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
...toggleState,
|
|
106
|
+
onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
|
|
107
|
+
};
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
// fallow-ignore-next-line complexity
|
|
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
91
126
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
92
127
|
const {
|
|
93
128
|
state: keyframeState,
|
|
129
|
+
isMotionPath: keyframeIsMotionPath,
|
|
130
|
+
pathEndpoint: keyframePathEndpoint,
|
|
94
131
|
willExtend: keyframeWillExtend,
|
|
95
132
|
onToggle: onToggleKeyframe,
|
|
96
133
|
} = useKeyframeToggle(domEditSession);
|
|
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
180
217
|
// toolbar layout never shifts.
|
|
181
218
|
<Tooltip
|
|
182
219
|
label={
|
|
183
|
-
|
|
184
|
-
? "
|
|
185
|
-
:
|
|
186
|
-
? "
|
|
187
|
-
:
|
|
220
|
+
keyframePathEndpoint
|
|
221
|
+
? "Motion path endpoints cannot be removed"
|
|
222
|
+
: !onToggleKeyframe
|
|
223
|
+
? "Select an animated element to add keyframes"
|
|
224
|
+
: keyframeIsMotionPath
|
|
188
225
|
? keyframeWillExtend
|
|
189
|
-
? "
|
|
190
|
-
:
|
|
191
|
-
|
|
226
|
+
? "Extend motion path to playhead (K)"
|
|
227
|
+
: keyframeState === "active"
|
|
228
|
+
? "Remove waypoint from motion path (K)"
|
|
229
|
+
: "Add waypoint to motion path (K)"
|
|
230
|
+
: keyframeState === "active"
|
|
231
|
+
? "Remove keyframe at playhead (K)"
|
|
232
|
+
: keyframeState === "inactive"
|
|
233
|
+
? keyframeWillExtend
|
|
234
|
+
? "Add keyframe at playhead, extends animation (K)"
|
|
235
|
+
: "Add keyframe at playhead (K)"
|
|
236
|
+
: "Add keyframe (K)"
|
|
192
237
|
}
|
|
193
238
|
>
|
|
194
239
|
<button
|
|
@@ -196,9 +241,17 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
196
241
|
disabled={!onToggleKeyframe}
|
|
197
242
|
onClick={onToggleKeyframe}
|
|
198
243
|
aria-label={
|
|
199
|
-
|
|
200
|
-
? "
|
|
201
|
-
:
|
|
244
|
+
keyframePathEndpoint
|
|
245
|
+
? "Motion path endpoint"
|
|
246
|
+
: keyframeIsMotionPath
|
|
247
|
+
? keyframeState === "active"
|
|
248
|
+
? "Remove motion path waypoint"
|
|
249
|
+
: keyframeWillExtend
|
|
250
|
+
? "Extend motion path to playhead"
|
|
251
|
+
: "Add motion path waypoint"
|
|
252
|
+
: keyframeState === "active"
|
|
253
|
+
? "Remove keyframe at playhead"
|
|
254
|
+
: "Add keyframe at playhead"
|
|
202
255
|
}
|
|
203
256
|
className={
|
|
204
257
|
!onToggleKeyframe
|
|
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
|
|
45
45
|
return presetConfig.ease;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
|
49
|
+
function renderCard({
|
|
50
|
+
animation = baseAnimation(),
|
|
51
|
+
defaultExpanded = true,
|
|
50
52
|
flat,
|
|
51
53
|
onUpdateMeta = vi.fn(),
|
|
52
54
|
onUpdateKeyframeEase = vi.fn(),
|
|
55
|
+
onDeleteAnimation = noop,
|
|
53
56
|
}: {
|
|
54
|
-
animation
|
|
57
|
+
animation?: GsapAnimation;
|
|
58
|
+
defaultExpanded?: boolean;
|
|
55
59
|
flat?: boolean;
|
|
56
60
|
onUpdateMeta?: ReturnType<typeof vi.fn>;
|
|
57
61
|
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
|
|
58
|
-
|
|
62
|
+
onDeleteAnimation?: (id: string) => void;
|
|
63
|
+
} = {}) {
|
|
59
64
|
const host = document.createElement("div");
|
|
60
65
|
document.body.append(host);
|
|
61
66
|
const root = createRoot(host);
|
|
@@ -63,11 +68,11 @@ function renderExpandedCard({
|
|
|
63
68
|
root.render(
|
|
64
69
|
<AnimationCard
|
|
65
70
|
animation={animation}
|
|
66
|
-
defaultExpanded
|
|
71
|
+
defaultExpanded={defaultExpanded}
|
|
67
72
|
flat={flat}
|
|
68
73
|
onUpdateProperty={noop}
|
|
69
74
|
onUpdateMeta={onUpdateMeta}
|
|
70
|
-
onDeleteAnimation={
|
|
75
|
+
onDeleteAnimation={onDeleteAnimation}
|
|
71
76
|
onAddProperty={noop}
|
|
72
77
|
onRemoveProperty={noop}
|
|
73
78
|
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
90
95
|
],
|
|
91
96
|
},
|
|
92
97
|
});
|
|
93
|
-
const view =
|
|
98
|
+
const view = renderCard({ animation, onUpdateKeyframeEase });
|
|
94
99
|
|
|
95
100
|
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
|
96
101
|
button.textContent?.includes("0% → 50%"),
|
|
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
101
106
|
|
|
102
107
|
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
103
108
|
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
|
|
109
|
+
action: "commit",
|
|
104
110
|
ease,
|
|
105
111
|
});
|
|
106
112
|
act(() => view.root.unmount());
|
|
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
110
116
|
const onUpdateMeta = vi.fn();
|
|
111
117
|
const onUpdateKeyframeEase = vi.fn();
|
|
112
118
|
const animation = baseAnimation({ id: "flat-tween" });
|
|
113
|
-
const view =
|
|
119
|
+
const view = renderCard({
|
|
114
120
|
animation,
|
|
115
121
|
flat: true,
|
|
116
122
|
onUpdateMeta,
|
|
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
128
134
|
|
|
129
135
|
describe("AnimationCard flat branch", () => {
|
|
130
136
|
it("renders a mint border-left and panel-token colors when flat", () => {
|
|
131
|
-
const host =
|
|
132
|
-
document.body.append(host);
|
|
133
|
-
const root = createRoot(host);
|
|
134
|
-
act(() => {
|
|
135
|
-
root.render(
|
|
136
|
-
<AnimationCard
|
|
137
|
-
animation={baseAnimation()}
|
|
138
|
-
defaultExpanded={false}
|
|
139
|
-
flat
|
|
140
|
-
onUpdateProperty={noop}
|
|
141
|
-
onUpdateMeta={noop}
|
|
142
|
-
onDeleteAnimation={noop}
|
|
143
|
-
onAddProperty={noop}
|
|
144
|
-
onRemoveProperty={noop}
|
|
145
|
-
/>,
|
|
146
|
-
);
|
|
147
|
-
});
|
|
137
|
+
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
|
|
148
138
|
const card = host.querySelector('[data-flat-effect-card="true"]');
|
|
149
139
|
expect(card).not.toBeNull();
|
|
150
140
|
expect(card?.className).toContain("border-panel-accent");
|
|
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
|
|
|
152
142
|
});
|
|
153
143
|
|
|
154
144
|
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
|
|
155
|
-
const host =
|
|
156
|
-
document.body.append(host);
|
|
157
|
-
const root = createRoot(host);
|
|
158
|
-
act(() => {
|
|
159
|
-
root.render(
|
|
160
|
-
<AnimationCard
|
|
161
|
-
animation={baseAnimation()}
|
|
162
|
-
defaultExpanded={false}
|
|
163
|
-
onUpdateProperty={noop}
|
|
164
|
-
onUpdateMeta={noop}
|
|
165
|
-
onDeleteAnimation={noop}
|
|
166
|
-
onAddProperty={noop}
|
|
167
|
-
onRemoveProperty={noop}
|
|
168
|
-
/>,
|
|
169
|
-
);
|
|
170
|
-
});
|
|
145
|
+
const { host, root } = renderCard({ defaultExpanded: false });
|
|
171
146
|
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
|
|
172
147
|
expect(host.textContent).toContain("power2.out");
|
|
173
148
|
act(() => root.unmount());
|
|
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
|
|
|
175
150
|
|
|
176
151
|
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
|
|
177
152
|
for (const flat of [false, true]) {
|
|
178
|
-
const host =
|
|
179
|
-
document.body.append(host);
|
|
180
|
-
const root = createRoot(host);
|
|
181
|
-
act(() => {
|
|
182
|
-
root.render(
|
|
183
|
-
<AnimationCard
|
|
184
|
-
animation={baseAnimation()}
|
|
185
|
-
defaultExpanded={false}
|
|
186
|
-
flat={flat || undefined}
|
|
187
|
-
onUpdateProperty={noop}
|
|
188
|
-
onUpdateMeta={noop}
|
|
189
|
-
onDeleteAnimation={noop}
|
|
190
|
-
onAddProperty={noop}
|
|
191
|
-
onRemoveProperty={noop}
|
|
192
|
-
/>,
|
|
193
|
-
);
|
|
194
|
-
});
|
|
153
|
+
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
|
|
195
154
|
expect(host.textContent).not.toContain("Remove");
|
|
196
155
|
const button = host.querySelector("button");
|
|
197
156
|
expect(button).not.toBeNull();
|
|
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
|
|
|
205
164
|
|
|
206
165
|
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
|
|
207
166
|
const onDeleteAnimation = vi.fn();
|
|
208
|
-
const host =
|
|
209
|
-
document.body.append(host);
|
|
210
|
-
const root = createRoot(host);
|
|
211
|
-
act(() => {
|
|
212
|
-
root.render(
|
|
213
|
-
<AnimationCard
|
|
214
|
-
animation={baseAnimation()}
|
|
215
|
-
defaultExpanded={true}
|
|
216
|
-
flat
|
|
217
|
-
onUpdateProperty={noop}
|
|
218
|
-
onUpdateMeta={noop}
|
|
219
|
-
onDeleteAnimation={onDeleteAnimation}
|
|
220
|
-
onAddProperty={noop}
|
|
221
|
-
onRemoveProperty={noop}
|
|
222
|
-
/>,
|
|
223
|
-
);
|
|
224
|
-
});
|
|
167
|
+
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
|
|
225
168
|
const buttons = Array.from(host.querySelectorAll("button"));
|
|
226
169
|
const removeButton = buttons.find((b) => b.textContent === "Remove");
|
|
227
170
|
expect(removeButton).not.toBeUndefined();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useCallback, useMemo, useState } from "react";
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
|
|
4
4
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
|
|
23
23
|
animation: GsapAnimation;
|
|
24
24
|
defaultExpanded: boolean;
|
|
25
25
|
flat?: boolean;
|
|
26
|
+
focusedSegment?: { tweenPercentage: number } | null;
|
|
27
|
+
onFocusSegmentConsumed?: () => void;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// fallow-ignore-next-line complexity
|
|
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
30
32
|
animation,
|
|
31
33
|
defaultExpanded,
|
|
32
34
|
flat,
|
|
35
|
+
focusedSegment,
|
|
36
|
+
onFocusSegmentConsumed,
|
|
33
37
|
onUpdateProperty,
|
|
34
38
|
onUpdateMeta,
|
|
35
39
|
onDeleteAnimation,
|
|
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
50
54
|
const [addingProp, setAddingProp] = useState(false);
|
|
51
55
|
const [addingFromProp, setAddingFromProp] = useState(false);
|
|
52
56
|
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
|
|
57
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const pendingAutoScrollRef = useRef(false);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!focusedSegment) return;
|
|
62
|
+
setExpanded(true);
|
|
63
|
+
pendingAutoScrollRef.current = true;
|
|
64
|
+
setExpandedKfPct(focusedSegment.tweenPercentage);
|
|
65
|
+
onFocusSegmentConsumed?.();
|
|
66
|
+
}, [focusedSegment, onFocusSegmentConsumed]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
|
|
70
|
+
const segment = cardRef.current?.querySelector<HTMLElement>(
|
|
71
|
+
`[data-ease-segment-pct="${expandedKfPct}"]`,
|
|
72
|
+
);
|
|
73
|
+
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
74
|
+
pendingAutoScrollRef.current = false;
|
|
75
|
+
}, [expandedKfPct]);
|
|
53
76
|
|
|
54
77
|
const usedProps = useMemo(
|
|
55
78
|
() => new Set(Object.keys(animation.properties)),
|
|
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
154
177
|
|
|
155
178
|
return (
|
|
156
179
|
<div
|
|
180
|
+
ref={cardRef}
|
|
157
181
|
data-flat-effect-card={flat ? "true" : undefined}
|
|
158
182
|
className={
|
|
159
183
|
flat
|
|
@@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
267
291
|
onToggle={setExpandedKfPct}
|
|
268
292
|
onEaseCommit={(pct, ease) => {
|
|
269
293
|
onUpdateKeyframeEase(animation.id, pct, ease);
|
|
270
|
-
trackStudioSegmentEaseEdit({ ease });
|
|
294
|
+
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
|
271
295
|
}}
|
|
272
296
|
onApplyAll={
|
|
273
297
|
onSetAllKeyframeEases
|