@hyperframes/studio 0.7.46 → 0.7.47

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 (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.46",
3
+ "version": "0.7.47",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.46",
50
- "@hyperframes/player": "0.7.46",
51
- "@hyperframes/parsers": "0.7.46",
52
- "@hyperframes/sdk": "0.7.46",
53
- "@hyperframes/studio-server": "0.7.46"
49
+ "@hyperframes/player": "0.7.47",
50
+ "@hyperframes/core": "0.7.47",
51
+ "@hyperframes/studio-server": "0.7.47",
52
+ "@hyperframes/sdk": "0.7.47",
53
+ "@hyperframes/parsers": "0.7.47"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.46"
68
+ "@hyperframes/producer": "0.7.47"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -11,15 +11,17 @@ import { usePanelLayout } from "./hooks/usePanelLayout";
11
11
  import { useFileManager } from "./hooks/useFileManager";
12
12
  import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
13
13
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
14
+ import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
14
15
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
15
16
  import { useDomEditSession } from "./hooks/useDomEditSession";
16
- import { useSdkSession } from "./hooks/useSdkSession";
17
17
  import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
18
+ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
19
+ import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
18
20
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
19
21
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
20
22
  import { useClipboard } from "./hooks/useClipboard";
21
23
  import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
22
- import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection";
24
+ import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
23
25
  import { useCaptionDetection } from "./hooks/useCaptionDetection";
24
26
  import { useRenderClipContent } from "./hooks/useRenderClipContent";
25
27
  import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
@@ -54,6 +56,7 @@ import { useServerConnection } from "./hooks/useServerConnection";
54
56
  import {
55
57
  normalizeStudioCompositionPath,
56
58
  readStudioUrlStateFromWindow,
59
+ resolveMasterCompositionPath,
57
60
  } from "./utils/studioUrlState";
58
61
  import { trackStudioSessionStart } from "./telemetry/events";
59
62
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
@@ -80,7 +83,7 @@ export function StudioApp() {
80
83
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
81
84
  const [compositionLoading, setCompositionLoading] = useState(true);
82
85
  const [refreshKey, setRefreshKey] = useState(0);
83
- const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
86
+ const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
84
87
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
85
88
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
86
89
  const activeCompPathRef = useRef(activeCompPath);
@@ -105,22 +108,6 @@ export function StudioApp() {
105
108
  : 0;
106
109
  return Math.max(timelineDuration, maxEnd);
107
110
  }, [timelineDuration, timelineElements]);
108
- const refreshTimersRef = useRef<number[]>([]);
109
- const refreshPreviewDocumentVersion = useCallback(() => {
110
- for (const id of refreshTimersRef.current) clearTimeout(id);
111
- refreshTimersRef.current = [];
112
- setPreviewDocumentVersion((v) => v + 1);
113
- refreshTimersRef.current.push(
114
- window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
115
- window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
116
- );
117
- }, []);
118
- useEffect(
119
- () => () => {
120
- for (const id of refreshTimersRef.current) clearTimeout(id);
121
- },
122
- [],
123
- );
124
111
  const [timelineVisible, setTimelineVisible] = useState(
125
112
  () =>
126
113
  initialUrlStateRef.current.timelineVisible ??
@@ -140,6 +127,7 @@ export function StudioApp() {
140
127
  });
141
128
  const editHistory = usePersistentEditHistory({ projectId });
142
129
  const domEditSaveTimestampRef = useRef(0);
130
+ const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
143
131
  const pendingTimelineEditPathRef = useRef(new Set<string>());
144
132
  const isGestureRecordingRef = useRef(false);
145
133
  const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
@@ -150,7 +138,16 @@ export function StudioApp() {
150
138
  domEditSaveTimestampRef,
151
139
  setRefreshKey,
152
140
  });
153
- const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
141
+ const masterCompPath = useMemo(
142
+ () => resolveMasterCompositionPath(fileManager.fileTree),
143
+ [fileManager.fileTree],
144
+ );
145
+ const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
146
+ projectId,
147
+ activeCompPath,
148
+ domEditSaveTimestampRef,
149
+ masterCompPath,
150
+ );
154
151
  useEffect(() => {
155
152
  if (activeCompPathHydrated) return;
156
153
  if (!fileManager.fileTreeLoaded) return;
@@ -186,8 +183,9 @@ export function StudioApp() {
186
183
  pendingTimelineEditPathRef,
187
184
  uploadProjectFiles: fileManager.uploadProjectFiles,
188
185
  isRecordingRef: isGestureRecordingRef,
189
- sdkSession: sdkHandle.session,
186
+ sdkSession: editFlowSdkSession,
190
187
  forceReloadSdkSession: sdkHandle.forceReload,
188
+ handleDomZIndexReorderCommitRef,
191
189
  });
192
190
  const {
193
191
  activeBlockParams,
@@ -302,25 +300,18 @@ export function StudioApp() {
302
300
  openSourceForSelection: fileManager.openSourceForSelection,
303
301
  selectSidebarTab: sidebarTabRef.current.select,
304
302
  getSidebarTab: sidebarTabRef.current.get,
305
- sdkSession: sdkHandle.session,
303
+ sdkSession: editFlowSdkSession,
306
304
  forceReloadSdkSession: sdkHandle.forceReload,
307
305
  });
308
306
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
307
+ handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
309
308
  clearDomSelectionRef.current = domEditSession.clearDomSelection;
310
309
  handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
311
310
  resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
312
311
  invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
313
- deleteSelectedKeyframesRef.current = () => {
314
- const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
315
- const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
316
- if (!a) return;
317
- // Only the active element's keyframes; a stale cross-element selection must not delete here.
318
- for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
319
- domEditSession.handleGsapRemoveKeyframe(a.id, p);
320
- }
321
- };
312
+ deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
322
313
  useSdkSelectionSync(
323
- sdkHandle.session,
314
+ editFlowSdkSession,
324
315
  domEditSession.domEditSelection,
325
316
  domEditSession.domEditGroupSelections,
326
317
  );
@@ -528,7 +519,10 @@ export function StudioApp() {
528
519
  handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
529
520
  handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
530
521
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
522
+ handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove}
523
+ handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
531
524
  handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
525
+ handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
532
526
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
533
527
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
534
528
  handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Wires the Design panel's promote-to-variable context. Promote/bind operates
3
+ * on the file the selected element actually lives in — a sub-composition file
4
+ * when you select an element inside an inlined sub-comp, not the host. So we
5
+ * open (and persist to) an SDK session keyed on `selection.sourceFile`, not the
6
+ * host `activeCompPath`. Declaring a variable therefore lands in the sub-comp's
7
+ * own file, making it a knob on that reusable frame everywhere it is used. When
8
+ * nothing is selected (or the element is top-level) the target is the active
9
+ * composition, so behavior there is unchanged.
10
+ */
11
+
12
+ import type { ReactNode } from "react";
13
+ import type { DomEditSelection } from "./editor/domEditingTypes";
14
+ import { useSdkSession } from "../hooks/useSdkSession";
15
+ import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
16
+ import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
17
+
18
+ /** Persist wiring minus the target — this provider derives the target from the selection. */
19
+ type PersistDeps = Omit<UseVariablesPersistParams, "sdkSession" | "activeCompPath">;
20
+
21
+ export function DesignPanelPromoteProvider({
22
+ selection,
23
+ projectId,
24
+ activeCompPath,
25
+ children,
26
+ ...persistDeps
27
+ }: PersistDeps & {
28
+ selection: DomEditSelection | null;
29
+ projectId: string | null;
30
+ activeCompPath: string | null;
31
+ children: ReactNode;
32
+ }) {
33
+ const targetPath = selection?.sourceFile || activeCompPath || "index.html";
34
+ const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
35
+ const persist = useVariablesPersist({
36
+ ...persistDeps,
37
+ sdkSession: handle.session,
38
+ activeCompPath: targetPath,
39
+ });
40
+ return (
41
+ <VariablePromoteProvider session={handle.session} selection={selection} persist={persist}>
42
+ {children}
43
+ </VariablePromoteProvider>
44
+ );
45
+ }
@@ -0,0 +1,31 @@
1
+ import { Tooltip } from "./ui";
2
+
3
+ /** Tab-bar button for the right inspector panel header. */
4
+ export function PanelTabButton({
5
+ label,
6
+ tooltip,
7
+ active,
8
+ onClick,
9
+ }: {
10
+ label: string;
11
+ tooltip: string;
12
+ active: boolean;
13
+ onClick: () => void;
14
+ }) {
15
+ return (
16
+ <Tooltip label={tooltip} side="bottom">
17
+ <button
18
+ type="button"
19
+ onClick={onClick}
20
+ aria-pressed={active}
21
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
22
+ active
23
+ ? "bg-neutral-800 text-white"
24
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
25
+ }`}
26
+ >
27
+ {label}
28
+ </button>
29
+ </Tooltip>
30
+ );
31
+ }
@@ -25,6 +25,15 @@ import { TimelineEditProvider } from "../contexts/TimelineEditContext";
25
25
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
26
26
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
27
27
  import type { GestureRecordingState } from "./editor/GestureRecordControl";
28
+ import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
29
+ import type {
30
+ TimelineGroupMoveChange,
31
+ TimelineGroupResizeChange,
32
+ } from "../hooks/useTimelineGroupEditing";
33
+ import {
34
+ formatTimelineAttributeNumber,
35
+ patchIframeDomTiming,
36
+ } from "../hooks/timelineEditingHelpers";
28
37
 
29
38
  export interface StudioPreviewAreaProps {
30
39
  timelineToolbar: ReactNode;
@@ -58,7 +67,10 @@ export interface StudioPreviewAreaProps {
58
67
  element: TimelineElement,
59
68
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
60
69
  ) => Promise<void> | void;
70
+ handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
71
+ handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
61
72
  handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
73
+ handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise<void> | void;
62
74
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
63
75
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
64
76
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -84,7 +96,10 @@ export function StudioPreviewArea({
84
96
  handleTimelineFileDrop,
85
97
  handleTimelineElementMove,
86
98
  handleTimelineElementResize,
99
+ handleTimelineGroupMove,
100
+ handleTimelineGroupResize,
87
101
  handleToggleTrackHidden,
102
+ handleToggleElementHidden,
88
103
  handleBlockedTimelineEdit,
89
104
  handleTimelineElementSplit,
90
105
  handleRazorSplit,
@@ -146,6 +161,9 @@ export function StudioPreviewArea({
146
161
  buildDomSelectionForTimelineElement,
147
162
  applyMarqueeSelection,
148
163
  } = useDomEditActionsContext();
164
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
165
+ const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
166
+ const timelineElements = usePlayerStore((s) => s.elements);
149
167
 
150
168
  // fallow-ignore-next-line complexity
151
169
  const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -158,6 +176,18 @@ export function StudioPreviewArea({
158
176
  };
159
177
  });
160
178
 
179
+ useTimelineSelectionPreviewSync({
180
+ selectedElementId,
181
+ selectedElementIds,
182
+ timelineElements,
183
+ domEditSelection,
184
+ domEditGroupSelections,
185
+ activeCompPath,
186
+ buildDomSelectionForTimelineElement,
187
+ applyDomSelection,
188
+ applyMarqueeSelection,
189
+ });
190
+
161
191
  // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
162
192
  // tween-relative percentage (shared by the delete/move keyframe callbacks): the
163
193
  // diamond reports a clip-% but the script ops key on the tween-%. Prefers the
@@ -176,19 +206,56 @@ export function StudioPreviewArea({
176
206
  [domEditSelection?.id, selectedGsapAnimations],
177
207
  );
178
208
 
209
+ const handleTimelineGroupMovePreview = useCallback(
210
+ (changes: TimelineGroupMoveChange[]) => {
211
+ for (const change of changes) {
212
+ patchIframeDomTiming(previewIframeRef.current, change.element, [
213
+ ["data-start", formatTimelineAttributeNumber(change.start)],
214
+ ]);
215
+ }
216
+ },
217
+ [previewIframeRef],
218
+ );
219
+
220
+ const handleTimelineGroupResizePreview = useCallback(
221
+ (changes: TimelineGroupResizeChange[]) => {
222
+ for (const change of changes) {
223
+ const attrs: Array<[string, string]> = [
224
+ ["data-start", formatTimelineAttributeNumber(change.start)],
225
+ ["data-duration", formatTimelineAttributeNumber(change.duration)],
226
+ ];
227
+ if (change.playbackStart != null) {
228
+ attrs.push([
229
+ change.element.playbackStartAttr === "playback-start"
230
+ ? "data-playback-start"
231
+ : "data-media-start",
232
+ formatTimelineAttributeNumber(change.playbackStart),
233
+ ]);
234
+ }
235
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
236
+ }
237
+ },
238
+ [previewIframeRef],
239
+ );
240
+
179
241
  // fallow-ignore-next-line complexity
180
242
  const timelineEditCallbacks = useMemo(
181
243
  () => ({
182
244
  onMoveElement: handleTimelineElementMove,
183
245
  onResizeElement: handleTimelineElementResize,
246
+ onMoveElements: handleTimelineGroupMove,
247
+ onResizeElements: handleTimelineGroupResize,
248
+ onPreviewMoveElements: handleTimelineGroupMovePreview,
249
+ onPreviewResizeElements: handleTimelineGroupResizePreview,
184
250
  onToggleTrackHidden: handleToggleTrackHidden,
251
+ onToggleElementHidden: handleToggleElementHidden,
185
252
  onBlockedEditAttempt: handleBlockedTimelineEdit,
186
253
  onSplitElement: handleTimelineElementSplit,
187
254
  onRazorSplit: handleRazorSplit,
188
255
  onRazorSplitAll: handleRazorSplitAll,
189
256
  onDeleteAllKeyframes: () => {
190
257
  // Hold the element where it is (collapse keyframes to a static set) rather
191
- // than deleting the whole animation deleting strands a stale GSAP base
258
+ // than deleting the whole animation, deleting strands a stale GSAP base
192
259
  // that the next drag adds to, flinging the element off-screen.
193
260
  const anim = selectedGsapAnimations.find((a) => a.keyframes);
194
261
  if (!anim) return;
@@ -208,7 +275,7 @@ export function StudioPreviewArea({
208
275
  // absolute time (via the clip's timing basis) and let resolveKeyframeRetime
209
276
  // decide: a drop inside the tween window is a plain move (re-key tween-%); a
210
277
  // drop past the boundary (last keyframe past the end, first before the start)
211
- // resizes the tween position/duration grow so the dragged keyframe lands at
278
+ // resizes the tween, position/duration grow so the dragged keyframe lands at
212
279
  // the drop while every other keyframe keeps its absolute time (value+ease too).
213
280
  // fallow-ignore-next-line complexity
214
281
  onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
@@ -281,7 +348,12 @@ export function StudioPreviewArea({
281
348
  [
282
349
  handleTimelineElementMove,
283
350
  handleTimelineElementResize,
351
+ handleTimelineGroupMove,
352
+ handleTimelineGroupMovePreview,
353
+ handleTimelineGroupResize,
354
+ handleTimelineGroupResizePreview,
284
355
  handleToggleTrackHidden,
356
+ handleToggleElementHidden,
285
357
  handleBlockedTimelineEdit,
286
358
  handleTimelineElementSplit,
287
359
  handleRazorSplit,
@@ -323,7 +395,7 @@ export function StudioPreviewArea({
323
395
  onCompositionLoadingChange={setCompositionLoading}
324
396
  onCompositionChange={(compPath) => {
325
397
  // Sync activeCompPath when user drills down via timeline double-click
326
- // or navigates back via breadcrumb keeps sidebar + thumbnails in sync.
398
+ // or navigates back via breadcrumb, keeps sidebar + thumbnails in sync.
327
399
  // Guard against no-op updates to prevent circular refresh cascades
328
400
  // between activeCompPath → compositionStack → onCompositionChange.
329
401
  if (compPath !== activeCompPath) {