@hyperframes/studio 0.7.18 → 0.7.20
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-DNLS_l47.js +459 -0
- package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
- package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
- package/dist/assets/index-WxXW8fW3.js +396 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1246 -807
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/StudioPreviewArea.tsx +92 -17
- package/src/components/TimelineToolbar.tsx +8 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -2
- package/src/components/editor/keyframeDrag.test.ts +195 -0
- package/src/components/editor/keyframeDrag.ts +105 -0
- package/src/components/editor/keyframeRetime.test.ts +140 -0
- package/src/components/editor/keyframeRetime.ts +121 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/gsapDragCommit.ts +28 -1
- package/src/hooks/gsapPositionDetection.ts +98 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -97
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
- package/src/hooks/useGsapKeyframeOps.ts +63 -2
- package/src/hooks/useGsapSelectionHandlers.ts +72 -1
- package/src/hooks/useGsapTweenCache.ts +16 -7
- package/src/hooks/useKeyframeKeyboard.ts +29 -32
- package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
- package/src/player/components/PlayerControls.tsx +40 -13
- package/src/player/components/Timeline.tsx +8 -0
- package/src/player/components/TimelineCanvas.tsx +7 -0
- package/src/player/components/TimelineClipDiamonds.tsx +109 -17
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +19 -0
- package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-D0468l1X.js +0 -375
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useRef, useState } from "react";
|
|
2
2
|
import { BEAT_BAND_H } from "./BeatStrip";
|
|
3
|
+
import {
|
|
4
|
+
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
5
|
+
previewClipPct,
|
|
6
|
+
resolveKeyframeDrag,
|
|
7
|
+
} from "../../components/editor/keyframeDrag";
|
|
3
8
|
|
|
4
9
|
interface KeyframeEntry {
|
|
5
10
|
percentage: number;
|
|
11
|
+
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
|
12
|
+
tweenPercentage?: number;
|
|
6
13
|
properties: Record<string, number | string>;
|
|
7
14
|
ease?: string;
|
|
8
15
|
}
|
|
@@ -29,6 +36,15 @@ interface TimelineClipDiamondsProps {
|
|
|
29
36
|
onClickKeyframe?: (percentage: number) => void;
|
|
30
37
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
31
38
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
39
|
+
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
|
|
40
|
+
* Both percentages are clip-relative: `fromClipPercentage` identifies the
|
|
41
|
+
* dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
|
|
42
|
+
* The handler decides move (within the tween) vs resize (past its boundary). */
|
|
43
|
+
onMoveKeyframe?: (
|
|
44
|
+
elementId: string,
|
|
45
|
+
fromClipPercentage: number,
|
|
46
|
+
toClipPercentage: number,
|
|
47
|
+
) => void;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
const DIAMOND_RATIO = 0.8;
|
|
@@ -39,6 +55,13 @@ const DIAMOND_RATIO = 0.8;
|
|
|
39
55
|
const KF_MIN_PCT = -5;
|
|
40
56
|
const KF_MAX_PCT = 105;
|
|
41
57
|
|
|
58
|
+
type DragState = {
|
|
59
|
+
kfKey: string;
|
|
60
|
+
startX: number;
|
|
61
|
+
fromClipPct: number;
|
|
62
|
+
moved: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
42
65
|
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
43
66
|
keyframesData,
|
|
44
67
|
clipWidthPx,
|
|
@@ -52,7 +75,15 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
52
75
|
onClickKeyframe,
|
|
53
76
|
onShiftClickKeyframe,
|
|
54
77
|
onContextMenuKeyframe,
|
|
78
|
+
onMoveKeyframe,
|
|
55
79
|
}: TimelineClipDiamondsProps) {
|
|
80
|
+
// Hooks must run before the early return below.
|
|
81
|
+
const dragRef = useRef<DragState | null>(null);
|
|
82
|
+
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
|
83
|
+
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
|
84
|
+
// on drop re-keys the diamond from source.
|
|
85
|
+
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
|
86
|
+
|
|
56
87
|
if (clipWidthPx < 20) return null;
|
|
57
88
|
|
|
58
89
|
// When the beat strip occupies the top band, shrink the diamonds and center
|
|
@@ -63,17 +94,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
63
94
|
const sorted = keyframesData.keyframes
|
|
64
95
|
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
|
65
96
|
.sort((a, b) => a.percentage - b.percentage);
|
|
97
|
+
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
|
|
98
|
+
// the whole row to bound the dragged diamond between its immediate siblings.
|
|
99
|
+
const sortedClipPcts = sorted.map((k) => k.percentage);
|
|
66
100
|
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
|
67
101
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
68
|
-
|
|
69
|
-
const handleClick = (e: React.MouseEvent, pct: number) => {
|
|
70
|
-
e.stopPropagation();
|
|
71
|
-
if (e.shiftKey) {
|
|
72
|
-
onShiftClickKeyframe?.(elementId, pct);
|
|
73
|
-
} else {
|
|
74
|
-
onClickKeyframe?.(pct);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
102
|
+
const canDrag = isSelected && !!onMoveKeyframe;
|
|
77
103
|
|
|
78
104
|
return (
|
|
79
105
|
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
|
@@ -102,17 +128,80 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
102
128
|
})}
|
|
103
129
|
|
|
104
130
|
{sorted.map((kf, i) => {
|
|
131
|
+
const kfKey = `${elementId}:${kf.percentage}`;
|
|
132
|
+
// While dragging this diamond, render it at the live preview clip-%.
|
|
133
|
+
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
|
105
134
|
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
|
106
135
|
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
|
107
|
-
// is the clip's left edge (the
|
|
108
|
-
// overflow-visible clip shows) — NOT shifted fully inside.
|
|
109
|
-
|
|
110
|
-
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
|
|
111
|
-
const kfKey = `${elementId}:${kf.percentage}`;
|
|
136
|
+
// is the clip's left edge (the left half overflows, which the
|
|
137
|
+
// overflow-visible clip shows) — NOT shifted fully inside.
|
|
138
|
+
const leftPx = (renderPct / 100) * clipWidthPx - half;
|
|
112
139
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
113
140
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
114
141
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
115
142
|
const color = isHighlighted ? accentColor : "#a3a3a3";
|
|
143
|
+
|
|
144
|
+
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
145
|
+
if (e.button !== 0) return;
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
if (canDrag) {
|
|
148
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
149
|
+
dragRef.current = {
|
|
150
|
+
kfKey,
|
|
151
|
+
startX: e.clientX,
|
|
152
|
+
fromClipPct: kf.percentage,
|
|
153
|
+
moved: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
158
|
+
const d = dragRef.current;
|
|
159
|
+
if (!d || d.kfKey !== kfKey) return;
|
|
160
|
+
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
|
|
161
|
+
d.moved = true;
|
|
162
|
+
}
|
|
163
|
+
if (d.moved) {
|
|
164
|
+
setPreview({
|
|
165
|
+
kfKey,
|
|
166
|
+
clipPct: previewClipPct({
|
|
167
|
+
pointerDownX: d.startX,
|
|
168
|
+
pointerMoveX: e.clientX,
|
|
169
|
+
clipWidthPx,
|
|
170
|
+
draggedClipPct: d.fromClipPct,
|
|
171
|
+
draggedIndex: i,
|
|
172
|
+
sortedClipPcts,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
178
|
+
const d = dragRef.current;
|
|
179
|
+
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
|
180
|
+
if (!d || d.kfKey !== kfKey) {
|
|
181
|
+
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
|
182
|
+
else onClickKeyframe?.(kf.percentage);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
dragRef.current = null;
|
|
187
|
+
setPreview(null);
|
|
188
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
189
|
+
const res = resolveKeyframeDrag({
|
|
190
|
+
pointerDownX: d.startX,
|
|
191
|
+
pointerUpX: e.clientX,
|
|
192
|
+
clipWidthPx,
|
|
193
|
+
draggedClipPct: d.fromClipPct,
|
|
194
|
+
draggedIndex: i,
|
|
195
|
+
sortedClipPcts,
|
|
196
|
+
});
|
|
197
|
+
if (res.kind === "click") {
|
|
198
|
+
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
|
199
|
+
else onClickKeyframe?.(kf.percentage);
|
|
200
|
+
} else if (res.kind === "move" && res.toClipPct != null) {
|
|
201
|
+
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
116
205
|
return (
|
|
117
206
|
<button
|
|
118
207
|
key={`${i}-${kf.percentage}`}
|
|
@@ -128,10 +217,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
128
217
|
pointerEvents: "auto",
|
|
129
218
|
background: "none",
|
|
130
219
|
border: "none",
|
|
131
|
-
cursor: "pointer",
|
|
220
|
+
cursor: canDrag ? "ew-resize" : "pointer",
|
|
132
221
|
padding: 0,
|
|
222
|
+
touchAction: "none",
|
|
133
223
|
}}
|
|
134
|
-
|
|
224
|
+
onPointerDown={onPointerDown}
|
|
225
|
+
onPointerMove={onPointerMove}
|
|
226
|
+
onPointerUp={onPointerUp}
|
|
135
227
|
onContextMenu={(e) => {
|
|
136
228
|
e.preventDefault();
|
|
137
229
|
e.stopPropagation();
|
|
@@ -39,5 +39,11 @@ export interface TimelineEditCallbacks {
|
|
|
39
39
|
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
40
40
|
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
41
41
|
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
42
|
+
onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
|
|
43
|
+
onMoveKeyframe?: (
|
|
44
|
+
elementId: string,
|
|
45
|
+
fromClipPercentage: number,
|
|
46
|
+
toClipPercentage: number,
|
|
47
|
+
) => void;
|
|
42
48
|
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
43
49
|
}
|
|
@@ -100,6 +100,35 @@ describe("applySoftReload", () => {
|
|
|
100
100
|
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
|
|
104
|
+
// Repro: an element dragged via gsap.set whose keyframes were then removed is
|
|
105
|
+
// no longer a timeline child, so the timeline-children sweep misses it. Its
|
|
106
|
+
// stale inline transform must still be cleared so it snaps back to its source
|
|
107
|
+
// (overlay) position instead of rendering offset.
|
|
108
|
+
const orphan = document.createElement("div");
|
|
109
|
+
orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
|
|
110
|
+
Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)
|
|
111
|
+
|
|
112
|
+
const scriptEl = document.createElement("script");
|
|
113
|
+
scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
|
|
114
|
+
const container = document.createElement("div");
|
|
115
|
+
container.appendChild(scriptEl);
|
|
116
|
+
|
|
117
|
+
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
|
|
118
|
+
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
|
119
|
+
querySelectorAll: (sel: string) =>
|
|
120
|
+
sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
|
|
121
|
+
createElement: (tag: string) => document.createElement(tag),
|
|
122
|
+
body: container,
|
|
123
|
+
head: document.createElement("div"),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
127
|
+
|
|
128
|
+
expect(orphan.style.transform).toBe(""); // stale GSAP transform stripped
|
|
129
|
+
expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
|
|
130
|
+
});
|
|
131
|
+
|
|
103
132
|
it("wraps execution in __hfSuppressSceneMutations when available", () => {
|
|
104
133
|
let suppressionCalled = false;
|
|
105
134
|
const { iframe } = buildMockIframe({
|
|
@@ -251,6 +251,25 @@ export function applySoftReload(
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// Also reset elements carrying a GSAP-applied inline `transform` that the
|
|
255
|
+
// timeline-children sweep above missed — a dragged element whose position
|
|
256
|
+
// was a standalone `gsap.set` (never a timeline child), or one whose
|
|
257
|
+
// keyframes were just removed (no longer in any timeline). Their last
|
|
258
|
+
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
|
|
259
|
+
// and the sweep above can't see it, so the element renders offset from its
|
|
260
|
+
// source position (matching the overlay) until a full reload. The clear
|
|
261
|
+
// below runs BEFORE the re-run, which re-applies the transform for any
|
|
262
|
+
// element the new script still animates.
|
|
263
|
+
const seenTargets = new Set<Element>(allTargets);
|
|
264
|
+
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
|
|
265
|
+
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
|
|
266
|
+
// never strip an authored, non-GSAP inline transform.
|
|
267
|
+
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
|
|
268
|
+
seenTargets.add(el);
|
|
269
|
+
allTargets.push(el);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
254
273
|
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
|
255
274
|
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
|
256
275
|
// nukes the element's CSS base (position, width, height, etc.) from the
|