@hyperframes/studio 0.6.122-alpha.0 → 0.7.1
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/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/assets/index-Z4bA3Fi0.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3768 -2245
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -41
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditPreviewSync.ts +3 -0
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +12 -11
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"dompurify": "^3.2.4",
|
|
46
46
|
"marked": "^14.1.4",
|
|
47
47
|
"mediabunny": "^1.45.3",
|
|
48
|
-
"@hyperframes/core": "0.
|
|
49
|
-
"@hyperframes/player": "0.
|
|
50
|
-
"@hyperframes/sdk": "0.
|
|
48
|
+
"@hyperframes/core": "0.7.1",
|
|
49
|
+
"@hyperframes/player": "0.7.1",
|
|
50
|
+
"@hyperframes/sdk": "0.7.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/react": "19",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"vite": "^6.4.2",
|
|
63
63
|
"vitest": "^3.2.4",
|
|
64
64
|
"zustand": "^5.0.0",
|
|
65
|
-
"@hyperframes/producer": "0.
|
|
65
|
+
"@hyperframes/producer": "0.7.1"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -428,6 +428,7 @@ export function StudioApp() {
|
|
|
428
428
|
domEditSelection: domEditSession.domEditSelection,
|
|
429
429
|
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
|
|
430
430
|
applyDomSelection: domEditSession.applyDomSelection,
|
|
431
|
+
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
431
432
|
initialState: initialUrlStateRef.current,
|
|
432
433
|
});
|
|
433
434
|
const studioCtxValue = buildStudioContextValue({
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
|
9
9
|
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
10
10
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
11
|
-
import { useDomEditActionsContext } from "../contexts/DomEditContext";
|
|
12
11
|
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
|
|
13
12
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
14
13
|
|
|
@@ -194,7 +193,6 @@ export function StudioHeader({
|
|
|
194
193
|
}: StudioHeaderProps) {
|
|
195
194
|
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
|
|
196
195
|
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
|
197
|
-
const { clearDomSelection } = useDomEditActionsContext();
|
|
198
196
|
|
|
199
197
|
return (
|
|
200
198
|
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
|
|
@@ -279,7 +277,8 @@ export function StudioHeader({
|
|
|
279
277
|
return;
|
|
280
278
|
}
|
|
281
279
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
|
282
|
-
|
|
280
|
+
// Keep the current selection when collapsing the Inspector — closing
|
|
281
|
+
// the panel shouldn't deselect the element.
|
|
283
282
|
setRightCollapsed(true);
|
|
284
283
|
}}
|
|
285
284
|
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
|
@@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout";
|
|
|
3
3
|
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
|
4
4
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
5
5
|
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
|
6
|
+
import { MotionPathOverlay } from "./editor/MotionPathOverlay";
|
|
7
|
+
import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
|
|
6
8
|
import { SnapToolbar } from "./editor/SnapToolbar";
|
|
7
9
|
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
|
8
10
|
import type { TimelineElement } from "../player";
|
|
@@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
10
12
|
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
|
11
13
|
import {
|
|
12
14
|
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
15
|
+
STUDIO_KEYFRAMES_ENABLED,
|
|
13
16
|
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
|
|
14
17
|
STUDIO_PREVIEW_SELECTION_ENABLED,
|
|
15
18
|
} from "./editor/manualEditingAvailability";
|
|
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
|
|
|
108
111
|
isPlaying,
|
|
109
112
|
refreshPreviewDocumentVersion,
|
|
110
113
|
} = useStudioPlaybackContext();
|
|
114
|
+
const compositionDimensions = useCompositionDimensions();
|
|
111
115
|
|
|
112
116
|
const {
|
|
113
117
|
domEditHoverSelection,
|
|
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
|
|
|
337
341
|
onToggleRecording={onToggleRecording}
|
|
338
342
|
/>
|
|
339
343
|
<SnapToolbar onSnapChange={setSnapPrefs} />
|
|
344
|
+
{STUDIO_KEYFRAMES_ENABLED && (
|
|
345
|
+
<MotionPathOverlay
|
|
346
|
+
iframeRef={previewIframeRef}
|
|
347
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
348
|
+
compositionSize={compositionDimensions}
|
|
349
|
+
isPlaying={isPlaying}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
340
352
|
{gestureOverlay}
|
|
341
353
|
</>
|
|
342
354
|
) : null
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useEnableKeyframes,
|
|
4
|
+
isPlayheadWithinTween,
|
|
5
|
+
type EnableKeyframesSession,
|
|
6
|
+
} from "../hooks/useEnableKeyframes";
|
|
7
|
+
import { computeElementPercentage } from "../hooks/gsapShared";
|
|
3
8
|
import {
|
|
4
9
|
getNextTimelineZoomPercent,
|
|
5
10
|
getTimelineZoomPercent,
|
|
@@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
44
49
|
const anims = session.selectedGsapAnimations;
|
|
45
50
|
const kfAnim = anims.find((a) => a.keyframes);
|
|
46
51
|
|
|
47
|
-
const computePct = (time: number) => {
|
|
48
|
-
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
|
|
49
|
-
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
|
|
50
|
-
return elDuration > 0
|
|
51
|
-
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
|
|
52
|
-
: 0;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
52
|
let state: "active" | "inactive" | "none" = "none";
|
|
53
|
+
// Outside the tween, clicking extends the animation to the playhead rather than
|
|
54
|
+
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
|
|
55
|
+
let willExtend = false;
|
|
56
56
|
if (kfAnim?.keyframes && sel) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
|
|
58
|
+
state = "inactive";
|
|
59
|
+
willExtend = true;
|
|
60
|
+
} else {
|
|
61
|
+
// Tween-relative percentage (not the clip range) so the button state matches
|
|
62
|
+
// where the keyframe would actually land.
|
|
63
|
+
const pct = computeElementPercentage(currentTime, sel, kfAnim);
|
|
64
|
+
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
|
65
|
+
? "active"
|
|
66
|
+
: "inactive";
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
return { state, onToggle: sel ? onToggle : undefined };
|
|
70
|
+
return { state, willExtend, onToggle: sel ? onToggle : undefined };
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
// fallow-ignore-next-line complexity
|
|
@@ -76,7 +83,11 @@ export function TimelineToolbar({
|
|
|
76
83
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
77
84
|
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
78
85
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
79
|
-
const {
|
|
86
|
+
const {
|
|
87
|
+
state: keyframeState,
|
|
88
|
+
willExtend: keyframeWillExtend,
|
|
89
|
+
onToggle: onToggleKeyframe,
|
|
90
|
+
} = useKeyframeToggle(domEditSession);
|
|
80
91
|
|
|
81
92
|
return (
|
|
82
93
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
@@ -124,7 +135,9 @@ export function TimelineToolbar({
|
|
|
124
135
|
keyframeState === "active"
|
|
125
136
|
? "Remove keyframe at playhead"
|
|
126
137
|
: keyframeState === "inactive"
|
|
127
|
-
?
|
|
138
|
+
? keyframeWillExtend
|
|
139
|
+
? "Add keyframe at playhead (extends animation)"
|
|
140
|
+
: "Add keyframe at playhead"
|
|
128
141
|
: "Enable keyframes"
|
|
129
142
|
}
|
|
130
143
|
>
|
|
@@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
|
|
|
282
282
|
};
|
|
283
283
|
|
|
284
284
|
let currentSelection: DomEditSelection | null = selection;
|
|
285
|
-
const onToggleRecording = vi.fn();
|
|
286
285
|
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
287
286
|
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
288
287
|
HTMLDivElement.prototype.setPointerCapture = () => {};
|
|
@@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
|
|
|
298
297
|
hoverSelection: null,
|
|
299
298
|
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
300
299
|
}),
|
|
301
|
-
recordingState: "idle",
|
|
302
|
-
onToggleRecording,
|
|
303
300
|
});
|
|
304
301
|
}
|
|
305
302
|
|
|
@@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
|
|
|
340
337
|
"drag",
|
|
341
338
|
expect.objectContaining({ button: 0 }),
|
|
342
339
|
);
|
|
343
|
-
const recordButton = host.querySelector(
|
|
344
|
-
'[aria-label="Record gesture (R)"]',
|
|
345
|
-
) as HTMLButtonElement;
|
|
346
|
-
expect(recordButton).toBeTruthy();
|
|
347
|
-
|
|
348
|
-
act(() => {
|
|
349
|
-
recordButton.click();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
expect(onToggleRecording).toHaveBeenCalledTimes(1);
|
|
353
340
|
|
|
354
341
|
act(() => {
|
|
355
342
|
root.unmount();
|
|
@@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
|
|
14
14
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
15
15
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
16
16
|
import { GridOverlay } from "./GridOverlay";
|
|
17
|
-
import {
|
|
17
|
+
import type { GestureRecordingState } from "./GestureRecordControl";
|
|
18
18
|
|
|
19
19
|
// Re-exports for external consumers — preserving existing import paths.
|
|
20
20
|
export {
|
|
@@ -55,6 +55,7 @@ interface DomEditOverlayProps {
|
|
|
55
55
|
onPathOffsetCommit: (
|
|
56
56
|
selection: DomEditSelection,
|
|
57
57
|
next: { x: number; y: number },
|
|
58
|
+
modifiers?: { altKey?: boolean },
|
|
58
59
|
) => Promise<void> | void;
|
|
59
60
|
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
|
|
60
61
|
onBoxSizeCommit: (
|
|
@@ -87,8 +88,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
87
88
|
onGroupPathOffsetCommit,
|
|
88
89
|
onBoxSizeCommit,
|
|
89
90
|
onRotationCommit,
|
|
90
|
-
recordingState,
|
|
91
|
-
onToggleRecording,
|
|
92
91
|
}: DomEditOverlayProps) {
|
|
93
92
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
94
93
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -243,6 +242,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
243
242
|
if (!selection) return "none";
|
|
244
243
|
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
|
|
245
244
|
}, [selection]);
|
|
245
|
+
|
|
246
246
|
const groupBounds = useMemo(
|
|
247
247
|
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
|
|
248
248
|
[groupOverlayItems],
|
|
@@ -433,13 +433,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
433
433
|
/>
|
|
434
434
|
</div>
|
|
435
435
|
)}
|
|
436
|
-
{onToggleRecording && (
|
|
437
|
-
<GestureRecordBadge
|
|
438
|
-
rect={overlayRect}
|
|
439
|
-
recordingState={recordingState}
|
|
440
|
-
onToggleRecording={onToggleRecording}
|
|
441
|
-
/>
|
|
442
|
-
)}
|
|
443
436
|
<div
|
|
444
437
|
key={selectionKey}
|
|
445
438
|
ref={boxRef}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clipToTweenPercentage } from "./KeyframeNavigation";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the
|
|
6
|
+
* GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek.
|
|
7
|
+
* The Layout-panel diamond used to emit clip-relative %, so the mutation missed
|
|
8
|
+
* every keyframe (off by the tween's offset/scale) → a silent no-op on disk that
|
|
9
|
+
* the optimistic cache hid, so the motion path never refreshed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// A tween that starts partway through the element's lifetime and is shorter than
|
|
13
|
+
// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60].
|
|
14
|
+
const KEYFRAMES = [
|
|
15
|
+
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
|
|
16
|
+
{ percentage: 30, tweenPercentage: 25, properties: { x: -180 } },
|
|
17
|
+
{ percentage: 50, tweenPercentage: 75, properties: { x: -320 } },
|
|
18
|
+
{ percentage: 60, tweenPercentage: 100, properties: { x: -460 } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe("clipToTweenPercentage", () => {
|
|
22
|
+
it("maps anchor keyframes to their tween-relative percentages", () => {
|
|
23
|
+
expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
|
|
24
|
+
expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("linearly interpolates a clip-relative playhead into tween space", () => {
|
|
28
|
+
// clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
|
|
29
|
+
expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to the input when there's no usable mapping", () => {
|
|
33
|
+
expect(clipToTweenPercentage([], 40)).toBe(40);
|
|
34
|
+
expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
|
|
|
3
3
|
|
|
4
4
|
interface KeyframeNavigationProps {
|
|
5
5
|
property: string;
|
|
6
|
-
/** All keyframes for this element's tween, or null if no keyframes exist
|
|
6
|
+
/** All keyframes for this element's tween, or null if no keyframes exist.
|
|
7
|
+
* `percentage` is clip-relative (element lifetime) for display/seek;
|
|
8
|
+
* `tweenPercentage` is the tween-relative value the writer/runtime key on. */
|
|
7
9
|
keyframes: Array<{
|
|
8
10
|
percentage: number;
|
|
11
|
+
tweenPercentage?: number;
|
|
9
12
|
properties: Record<string, number | string>;
|
|
10
13
|
ease?: string;
|
|
11
14
|
}> | null;
|
|
@@ -19,6 +22,26 @@ interface KeyframeNavigationProps {
|
|
|
19
22
|
|
|
20
23
|
const TOLERANCE = 0.5;
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
|
|
27
|
+
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
|
|
28
|
+
* map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
|
|
29
|
+
* pairs. Falls back to the input when there's no usable mapping (e.g. parser
|
|
30
|
+
* keyframes that are already tween-relative, or fewer than two anchors).
|
|
31
|
+
*/
|
|
32
|
+
export function clipToTweenPercentage(
|
|
33
|
+
keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
|
|
34
|
+
clipPct: number,
|
|
35
|
+
): number {
|
|
36
|
+
const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
|
|
37
|
+
if (mapped.length < 2) return clipPct;
|
|
38
|
+
const a = mapped[0]!;
|
|
39
|
+
const b = mapped[mapped.length - 1]!;
|
|
40
|
+
if (b.percentage === a.percentage) return a.tweenPercentage!;
|
|
41
|
+
const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
|
|
42
|
+
return a.tweenPercentage! + (clipPct - a.percentage) * slope;
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
function ArrowLeft({ disabled }: { disabled: boolean }) {
|
|
23
46
|
return (
|
|
24
47
|
<svg
|
|
@@ -94,13 +117,20 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|
|
94
117
|
diamondState = "ghost";
|
|
95
118
|
}
|
|
96
119
|
|
|
120
|
+
// Keyframe add/remove are keyed by TWEEN-relative percentage (what the GSAP
|
|
121
|
+
// writer + runtime use), not the clip-relative `currentPercentage` used for
|
|
122
|
+
// display/seek. Removing on an existing keyframe uses its own tweenPercentage;
|
|
123
|
+
// adding converts the clip-relative playhead through the keyframes' own
|
|
124
|
+
// clip→tween linear mapping. Passing clip-relative % made the mutation miss
|
|
125
|
+
// every keyframe (off by the tween's offset/scale) → a silent no-op on disk
|
|
126
|
+
// while the optimistic cache hid it, so the motion path never refreshed.
|
|
97
127
|
const handleDiamondClick = () => {
|
|
98
128
|
if (diamondState === "ghost") {
|
|
99
129
|
onConvertToKeyframes();
|
|
100
|
-
} else if (diamondState === "active") {
|
|
101
|
-
onRemoveKeyframe(
|
|
130
|
+
} else if (diamondState === "active" && atCurrent) {
|
|
131
|
+
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
|
|
102
132
|
} else {
|
|
103
|
-
onAddKeyframe(currentPercentage);
|
|
133
|
+
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
|
|
104
134
|
}
|
|
105
135
|
};
|
|
106
136
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
// Editor primary color (themeable via --hf-accent). Applied through inline
|
|
4
|
+
// style because CSS var() isn't valid in SVG presentation attributes.
|
|
5
|
+
export const ACCENT = "var(--hf-accent, #3CE6AC)";
|
|
6
|
+
|
|
7
|
+
/** One path node: a diamond (matching the timeline keyframe), a wider transparent
|
|
8
|
+
* grab target (when editable), and a hover-revealed × delete badge (when removable). */
|
|
9
|
+
export function MotionPathNode(props: {
|
|
10
|
+
cx: number;
|
|
11
|
+
cy: number;
|
|
12
|
+
r: number;
|
|
13
|
+
interactive: boolean;
|
|
14
|
+
removable: boolean;
|
|
15
|
+
grabbing: boolean;
|
|
16
|
+
selected: boolean;
|
|
17
|
+
onEnter: () => void;
|
|
18
|
+
onLeave: () => void;
|
|
19
|
+
onPointerDown: (e: React.PointerEvent) => void;
|
|
20
|
+
onPointerMove: (e: React.PointerEvent) => void;
|
|
21
|
+
onPointerUp: (e: React.PointerEvent) => void;
|
|
22
|
+
onRemove: (e: React.PointerEvent) => void;
|
|
23
|
+
onContextMenu?: (e: React.MouseEvent) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const { cx, cy, r, interactive, removable, grabbing, selected } = props;
|
|
26
|
+
const bx = cx + r * 1.8;
|
|
27
|
+
const by = cy - r * 1.8;
|
|
28
|
+
const k = r * 0.55;
|
|
29
|
+
// Diamond matching the timeline keyframe (a 45°-rotated rounded square).
|
|
30
|
+
// `side` is chosen so the diamond's points reach ~`r` from center, matching the
|
|
31
|
+
// old dot's footprint; selection is shown by enlarging it (no extra shape).
|
|
32
|
+
const side = (selected ? r * 1.5 : r) * 1.414;
|
|
33
|
+
return (
|
|
34
|
+
<g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
|
|
35
|
+
<rect
|
|
36
|
+
x={cx - side / 2}
|
|
37
|
+
y={cy - side / 2}
|
|
38
|
+
width={side}
|
|
39
|
+
height={side}
|
|
40
|
+
rx={side * 0.17}
|
|
41
|
+
transform={`rotate(45 ${cx} ${cy})`}
|
|
42
|
+
stroke="#0b0f1a"
|
|
43
|
+
strokeWidth={1.5}
|
|
44
|
+
vectorEffect="non-scaling-stroke"
|
|
45
|
+
style={{ fill: ACCENT }}
|
|
46
|
+
/>
|
|
47
|
+
{interactive && (
|
|
48
|
+
<circle
|
|
49
|
+
cx={cx}
|
|
50
|
+
cy={cy}
|
|
51
|
+
r={r * 2.4}
|
|
52
|
+
fill="transparent"
|
|
53
|
+
className="pointer-events-auto"
|
|
54
|
+
style={{ cursor: grabbing ? "grabbing" : "grab" }}
|
|
55
|
+
onPointerDown={props.onPointerDown}
|
|
56
|
+
onPointerMove={props.onPointerMove}
|
|
57
|
+
onPointerUp={props.onPointerUp}
|
|
58
|
+
onContextMenu={props.onContextMenu}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
{removable && (
|
|
62
|
+
<g
|
|
63
|
+
className="pointer-events-auto"
|
|
64
|
+
style={{ cursor: "pointer" }}
|
|
65
|
+
onPointerDown={props.onRemove}
|
|
66
|
+
>
|
|
67
|
+
<circle
|
|
68
|
+
cx={bx}
|
|
69
|
+
cy={by}
|
|
70
|
+
r={r * 1.3}
|
|
71
|
+
stroke="#0b0f1a"
|
|
72
|
+
strokeWidth={1}
|
|
73
|
+
vectorEffect="non-scaling-stroke"
|
|
74
|
+
style={{ fill: ACCENT }}
|
|
75
|
+
/>
|
|
76
|
+
<line
|
|
77
|
+
x1={bx - k}
|
|
78
|
+
y1={by - k}
|
|
79
|
+
x2={bx + k}
|
|
80
|
+
y2={by + k}
|
|
81
|
+
stroke="#0b0f1a"
|
|
82
|
+
strokeWidth={1.5}
|
|
83
|
+
vectorEffect="non-scaling-stroke"
|
|
84
|
+
/>
|
|
85
|
+
<line
|
|
86
|
+
x1={bx + k}
|
|
87
|
+
y1={by - k}
|
|
88
|
+
x2={bx - k}
|
|
89
|
+
y2={by + k}
|
|
90
|
+
stroke="#0b0f1a"
|
|
91
|
+
strokeWidth={1.5}
|
|
92
|
+
vectorEffect="non-scaling-stroke"
|
|
93
|
+
/>
|
|
94
|
+
</g>
|
|
95
|
+
)}
|
|
96
|
+
</g>
|
|
97
|
+
);
|
|
98
|
+
}
|