@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.
Files changed (44) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-BDx28x9R.js → index-CURAsFZX.js} +1 -1
  3. package/dist/assets/index-DXWeH-EY.js +396 -0
  4. package/dist/assets/{index-BCAdHPz_.js → index-hueu10iU.js} +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1446 -866
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/App.tsx +2 -1
  11. package/src/components/StudioPreviewArea.tsx +92 -17
  12. package/src/components/TimelineToolbar.tsx +8 -0
  13. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  14. package/src/components/editor/keyframeDrag.test.ts +195 -0
  15. package/src/components/editor/keyframeDrag.ts +105 -0
  16. package/src/components/editor/keyframeRetime.test.ts +140 -0
  17. package/src/components/editor/keyframeRetime.ts +121 -0
  18. package/src/contexts/DomEditContext.tsx +12 -0
  19. package/src/contexts/TimelineEditContext.tsx +2 -0
  20. package/src/hooks/gsapDragCommit.ts +28 -1
  21. package/src/hooks/gsapPositionDetection.ts +98 -0
  22. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  24. package/src/hooks/useDomEditSession.ts +22 -0
  25. package/src/hooks/useDomEditWiring.ts +17 -0
  26. package/src/hooks/useDomSelection.ts +4 -1
  27. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  28. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  29. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  30. package/src/hooks/useGsapTweenCache.ts +16 -7
  31. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  32. package/src/hooks/useStudioSelectionPublisher.ts +102 -0
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  34. package/src/player/components/PlayerControls.tsx +40 -13
  35. package/src/player/components/Timeline.tsx +8 -0
  36. package/src/player/components/TimelineCanvas.tsx +7 -0
  37. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  38. package/src/player/components/timelineCallbacks.ts +6 -0
  39. package/src/utils/gsapSoftReload.test.ts +29 -0
  40. package/src/utils/gsapSoftReload.ts +19 -0
  41. package/src/utils/studioSelectionSnapshot.test.ts +47 -0
  42. package/src/utils/studioSelectionSnapshot.ts +72 -0
  43. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  44. 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.tweenPercentage ?? state.percentage);
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
- function PlayIcon() {
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
- function PauseIcon() {
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
- <svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
28
- <rect x="6" y="4" width="4" height="16" rx="1" />
29
- <rect x="14" y="4" width="4" height="16" rx="1" />
30
- </svg>
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 ? <PauseIcon /> : <PlayIcon />}
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 diamond's left half overflows, which the
108
- // overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
109
- // boundary keyframes (0% / 100%) would render off-center.
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
- onClick={(e) => handleClick(e, kf.percentage)}
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
+ }