@hyperframes/studio 0.7.17 → 0.7.19
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-BDx28x9R.js → index-CURAsFZX.js} +1 -1
- package/dist/assets/index-DXWeH-EY.js +396 -0
- package/dist/assets/{index-BCAdHPz_.js → index-hueu10iU.js} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1446 -866
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +2 -1
- 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 +22 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- package/src/hooks/useDomSelection.ts +4 -1
- 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/hooks/useStudioSelectionPublisher.ts +102 -0
- 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/src/utils/studioSelectionSnapshot.test.ts +47 -0
- package/src/utils/studioSelectionSnapshot.ts +72 -0
- package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-hiqLDAtz.js +0 -375
|
@@ -18,6 +18,8 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
18
18
|
onDeleteAll: (elementId: string) => void;
|
|
19
19
|
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
20
20
|
onCopyProperties?: (elementId: string, percentage: number) => void;
|
|
21
|
+
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
22
|
+
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
|
@@ -25,11 +27,12 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
25
27
|
onClose,
|
|
26
28
|
onDelete,
|
|
27
29
|
onDeleteAll,
|
|
30
|
+
onMoveToPlayhead,
|
|
28
31
|
}: KeyframeDiamondContextMenuProps) {
|
|
29
32
|
const menuRef = useContextMenuDismiss(onClose);
|
|
30
33
|
|
|
31
34
|
const menuWidth = 200;
|
|
32
|
-
const menuHeight = 70;
|
|
35
|
+
const menuHeight = onMoveToPlayhead ? 100 : 70;
|
|
33
36
|
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
34
37
|
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
35
38
|
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
@@ -40,12 +43,28 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
40
43
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
41
44
|
style={{ left: adjustedX, top: adjustedY }}
|
|
42
45
|
>
|
|
46
|
+
{onMoveToPlayhead && (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
|
|
50
|
+
onClick={() => {
|
|
51
|
+
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
|
52
|
+
// and returns the tween-% for the mutation. Passing tween-% here would
|
|
53
|
+
// miss the lookup on any tween whose window is shorter than the clip.
|
|
54
|
+
onMoveToPlayhead(state.elementId, state.percentage);
|
|
55
|
+
onClose();
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
Move to Playhead
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
|
|
43
62
|
{/* Delete */}
|
|
44
63
|
<button
|
|
45
64
|
type="button"
|
|
46
65
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
47
66
|
onClick={() => {
|
|
48
|
-
onDelete(state.elementId, state.
|
|
67
|
+
onDelete(state.elementId, state.percentage);
|
|
49
68
|
onClose();
|
|
50
69
|
}}
|
|
51
70
|
>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useRef, useCallback, useEffect, memo } from "react";
|
|
2
|
+
import gsap from "gsap";
|
|
3
|
+
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
|
2
4
|
import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
|
|
3
5
|
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
|
4
6
|
import { usePlayerStore } from "../store/playerStore";
|
|
@@ -14,20 +16,45 @@ type TimeDisplayMode = "time" | "frame";
|
|
|
14
16
|
|
|
15
17
|
/* ── Icon sub-components ─────────────────────────────────────────── */
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
20
|
-
<polygon points="6,3 20,12 6,21" />
|
|
21
|
-
</svg>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
19
|
+
gsap.registerPlugin(MorphSVGPlugin);
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
// Play glyph: the right-hand blade from the HyperFrames favicon (points right).
|
|
22
|
+
// Pause glyph: two bars centred in the same coordinate space so MorphSVG can
|
|
23
|
+
// tween one `d` into the other. Both shapes live in the favicon's 0-100 space
|
|
24
|
+
// and the svg viewBox frames the blade's bounding box.
|
|
25
|
+
const PLAY_BLADE_D =
|
|
26
|
+
"M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z";
|
|
27
|
+
const PAUSE_BARS_D = "M56 28H67V71H56Z M73 28H84V71H73Z";
|
|
28
|
+
|
|
29
|
+
// Morph the play blade <-> pause bars on toggle via GSAP MorphSVG. Both glyphs
|
|
30
|
+
// are one path whose `d` tweens; the initial render matches `playing` with no
|
|
31
|
+
// animation, and prefers-reduced-motion snaps instead of tweening.
|
|
32
|
+
function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
|
|
33
|
+
const pathRef = useRef<SVGPathElement>(null);
|
|
34
|
+
const isFirstRun = useRef(true);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const el = pathRef.current;
|
|
37
|
+
if (!el) return;
|
|
38
|
+
const target = playing ? PAUSE_BARS_D : PLAY_BLADE_D;
|
|
39
|
+
const reduceMotion =
|
|
40
|
+
typeof window !== "undefined" &&
|
|
41
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
42
|
+
if (isFirstRun.current || reduceMotion) {
|
|
43
|
+
isFirstRun.current = false;
|
|
44
|
+
gsap.set(el, { morphSVG: target });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const tween = gsap.to(el, { duration: 0.28, ease: "power2.inOut", morphSVG: target });
|
|
48
|
+
return () => {
|
|
49
|
+
tween.kill();
|
|
50
|
+
};
|
|
51
|
+
}, [playing]);
|
|
26
52
|
return (
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
<span className="relative inline-flex h-3 w-3 items-center justify-center" aria-hidden="true">
|
|
54
|
+
<svg width="12" height="12" viewBox="46 21 54 56" fill="#FAFAFA">
|
|
55
|
+
<path ref={pathRef} d={playing ? PAUSE_BARS_D : PLAY_BLADE_D} />
|
|
56
|
+
</svg>
|
|
57
|
+
</span>
|
|
31
58
|
);
|
|
32
59
|
}
|
|
33
60
|
|
|
@@ -420,7 +447,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
420
447
|
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
421
448
|
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
422
449
|
>
|
|
423
|
-
{isPlaying
|
|
450
|
+
<PlayPauseMorphIcon playing={isPlaying} />
|
|
424
451
|
</button>
|
|
425
452
|
</Tooltip>
|
|
426
453
|
|
|
@@ -86,6 +86,8 @@ export const Timeline = memo(function Timeline({
|
|
|
86
86
|
onDeleteKeyframe,
|
|
87
87
|
onDeleteAllKeyframes,
|
|
88
88
|
onChangeKeyframeEase,
|
|
89
|
+
onMoveKeyframeToPlayhead,
|
|
90
|
+
onMoveKeyframe,
|
|
89
91
|
} = useResolvedTimelineEditCallbacks({
|
|
90
92
|
onMoveElement: onMoveElementOverride,
|
|
91
93
|
onResizeElement: onResizeElementOverride,
|
|
@@ -479,6 +481,7 @@ export const Timeline = memo(function Timeline({
|
|
|
479
481
|
onShiftClickKeyframe={(elId, pct) => {
|
|
480
482
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
481
483
|
}}
|
|
484
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
482
485
|
onContextMenuKeyframe={(e, elId, pct) => {
|
|
483
486
|
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
|
484
487
|
if (el) {
|
|
@@ -556,6 +559,11 @@ export const Timeline = memo(function Timeline({
|
|
|
556
559
|
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
|
557
560
|
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
|
558
561
|
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
|
562
|
+
onMoveToPlayhead={
|
|
563
|
+
onMoveKeyframeToPlayhead
|
|
564
|
+
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
|
|
565
|
+
: undefined
|
|
566
|
+
}
|
|
559
567
|
onCopyProperties={(elId, pct) => {
|
|
560
568
|
const kfData = keyframeCache.get(elId);
|
|
561
569
|
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
|
@@ -92,6 +92,11 @@ interface TimelineCanvasProps {
|
|
|
92
92
|
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
|
93
93
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
94
94
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
95
|
+
onMoveKeyframe?: (
|
|
96
|
+
elementId: string,
|
|
97
|
+
fromClipPercentage: number,
|
|
98
|
+
toClipPercentage: number,
|
|
99
|
+
) => void;
|
|
95
100
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
96
101
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
97
102
|
}
|
|
@@ -139,6 +144,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
139
144
|
onClickKeyframe,
|
|
140
145
|
onShiftClickKeyframe,
|
|
141
146
|
onContextMenuKeyframe,
|
|
147
|
+
onMoveKeyframe,
|
|
142
148
|
onContextMenuClip,
|
|
143
149
|
beatAnalysis,
|
|
144
150
|
}: TimelineCanvasProps) {
|
|
@@ -439,6 +445,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
439
445
|
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
|
440
446
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
441
447
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
448
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
442
449
|
/>
|
|
443
450
|
)}
|
|
444
451
|
</TimelineClip>
|
|
@@ -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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
|
|
5
|
+
describe("buildStudioSelectionSnapshot", () => {
|
|
6
|
+
it("serializes a DOM edit selection without the live HTMLElement", () => {
|
|
7
|
+
const selection = {
|
|
8
|
+
element: { tagName: "H1" } as HTMLElement,
|
|
9
|
+
id: null,
|
|
10
|
+
hfId: "hero-title",
|
|
11
|
+
selector: ".title",
|
|
12
|
+
selectorIndex: 0,
|
|
13
|
+
label: "Hero title",
|
|
14
|
+
tagName: "h1",
|
|
15
|
+
sourceFile: "index.html",
|
|
16
|
+
compositionPath: "index.html",
|
|
17
|
+
isCompositionHost: false,
|
|
18
|
+
isInsideLockedComposition: false,
|
|
19
|
+
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
|
|
20
|
+
textContent: "Launch faster",
|
|
21
|
+
dataAttributes: { "data-hf-id": "hero-title" },
|
|
22
|
+
inlineStyles: { color: "white" },
|
|
23
|
+
computedStyles: { "font-size": "48px" },
|
|
24
|
+
textFields: [],
|
|
25
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
26
|
+
} as DomEditSelection;
|
|
27
|
+
|
|
28
|
+
const snapshot = buildStudioSelectionSnapshot({
|
|
29
|
+
projectId: "demo",
|
|
30
|
+
selection,
|
|
31
|
+
currentTime: 1.25,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(snapshot).toMatchObject({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
projectId: "demo",
|
|
37
|
+
compositionPath: "index.html",
|
|
38
|
+
sourceFile: "index.html",
|
|
39
|
+
currentTime: 1.25,
|
|
40
|
+
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
|
|
41
|
+
thumbnailUrl:
|
|
42
|
+
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
|
|
43
|
+
});
|
|
44
|
+
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
|
|
45
|
+
expect(snapshot).not.toHaveProperty("element");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
function round3(value: number): number {
|
|
5
|
+
return Math.round(value * 1000) / 1000;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function thumbnailUrl({
|
|
9
|
+
projectId,
|
|
10
|
+
selection,
|
|
11
|
+
currentTime,
|
|
12
|
+
}: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
selection: DomEditSelection;
|
|
15
|
+
currentTime: number;
|
|
16
|
+
}): string {
|
|
17
|
+
const compPath = encodeURIComponent(
|
|
18
|
+
selection.compositionPath || selection.sourceFile || "index.html",
|
|
19
|
+
);
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
t: String(round3(currentTime)),
|
|
22
|
+
format: "png",
|
|
23
|
+
});
|
|
24
|
+
if (selection.selector) params.set("selector", selection.selector);
|
|
25
|
+
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
|
|
26
|
+
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildStudioSelectionSnapshot({
|
|
30
|
+
projectId,
|
|
31
|
+
selection,
|
|
32
|
+
currentTime,
|
|
33
|
+
}: {
|
|
34
|
+
projectId: string;
|
|
35
|
+
selection: DomEditSelection;
|
|
36
|
+
currentTime: number;
|
|
37
|
+
}): StudioSelectionSnapshot {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
projectId,
|
|
41
|
+
compositionPath: selection.compositionPath,
|
|
42
|
+
sourceFile: selection.sourceFile,
|
|
43
|
+
currentTime: round3(currentTime),
|
|
44
|
+
target: {
|
|
45
|
+
id: selection.id,
|
|
46
|
+
hfId: selection.hfId,
|
|
47
|
+
selector: selection.selector,
|
|
48
|
+
selectorIndex: selection.selectorIndex,
|
|
49
|
+
},
|
|
50
|
+
label: selection.label,
|
|
51
|
+
tagName: selection.tagName,
|
|
52
|
+
boundingBox: {
|
|
53
|
+
x: round3(selection.boundingBox.x),
|
|
54
|
+
y: round3(selection.boundingBox.y),
|
|
55
|
+
width: round3(selection.boundingBox.width),
|
|
56
|
+
height: round3(selection.boundingBox.height),
|
|
57
|
+
},
|
|
58
|
+
textContent: selection.textContent,
|
|
59
|
+
dataAttributes: { ...selection.dataAttributes },
|
|
60
|
+
inlineStyles: { ...selection.inlineStyles },
|
|
61
|
+
computedStyles: { ...selection.computedStyles },
|
|
62
|
+
textFields: selection.textFields.map((field) => ({
|
|
63
|
+
key: field.key,
|
|
64
|
+
label: field.label,
|
|
65
|
+
value: field.value,
|
|
66
|
+
tagName: field.tagName,
|
|
67
|
+
source: field.source,
|
|
68
|
+
})),
|
|
69
|
+
capabilities: { ...selection.capabilities },
|
|
70
|
+
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
|
|
71
|
+
};
|
|
72
|
+
}
|