@hyperframes/studio 0.7.14 → 0.7.16

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 (72) hide show
  1. package/dist/assets/index-C4cUbqql.js +375 -0
  2. package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
  5. package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +23 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3158 -2809
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -5
  13. package/src/App.tsx +2 -0
  14. package/src/components/StudioPreviewArea.tsx +0 -41
  15. package/src/components/StudioRightPanel.tsx +2 -0
  16. package/src/components/TimelineToolbar.tsx +4 -4
  17. package/src/components/editor/DomEditOverlay.tsx +5 -2
  18. package/src/components/editor/GestureRecordControl.tsx +2 -2
  19. package/src/components/editor/GsapAnimationSection.tsx +3 -3
  20. package/src/components/editor/InspectorHeaderActions.tsx +62 -0
  21. package/src/components/editor/LayersPanel.tsx +35 -2
  22. package/src/components/editor/MotionPathOverlay.tsx +24 -8
  23. package/src/components/editor/PropertyPanel.tsx +48 -60
  24. package/src/components/editor/Transform3DCube.tsx +84 -85
  25. package/src/components/editor/domEditOverlayGeometry.ts +28 -0
  26. package/src/components/editor/domEditingDom.ts +36 -2
  27. package/src/components/editor/domEditingGroups.ts +41 -0
  28. package/src/components/editor/domEditingLayers.test.ts +97 -1
  29. package/src/components/editor/domEditingLayers.ts +13 -29
  30. package/src/components/editor/domEditingTypes.ts +4 -1
  31. package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
  32. package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
  33. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  34. package/src/components/editor/manualEditsTypes.ts +1 -1
  35. package/src/components/editor/manualOffsetDrag.ts +23 -2
  36. package/src/components/editor/motionPathCommit.test.ts +1 -1
  37. package/src/components/editor/motionPathSelection.ts +1 -1
  38. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  39. package/src/components/editor/propertyPanelHelpers.ts +6 -4
  40. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  41. package/src/components/editor/useMotionPathData.ts +47 -1
  42. package/src/contexts/DomEditContext.tsx +16 -0
  43. package/src/contexts/TimelineEditContext.tsx +0 -1
  44. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  45. package/src/hooks/gsapTweenSynth.ts +64 -0
  46. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  47. package/src/hooks/useAppHotkeys.ts +34 -3
  48. package/src/hooks/useDomEditSession.ts +48 -0
  49. package/src/hooks/useDomSelection.ts +73 -9
  50. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  51. package/src/hooks/useEnableKeyframes.ts +29 -1
  52. package/src/hooks/useGroupCommits.ts +188 -0
  53. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  54. package/src/hooks/useGsapTweenCache.ts +56 -72
  55. package/src/hooks/usePreviewInteraction.ts +60 -2
  56. package/src/player/components/ShortcutsPanel.tsx +2 -0
  57. package/src/player/components/Timeline.tsx +0 -15
  58. package/src/player/components/TimelineCanvas.tsx +0 -13
  59. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  60. package/src/player/components/timelineCallbacks.ts +0 -1
  61. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  62. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  63. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  64. package/src/player/store/playerStore.ts +20 -0
  65. package/src/utils/sdkCutoverParity.test.ts +1 -1
  66. package/src/utils/studioPreviewHelpers.ts +37 -0
  67. package/dist/assets/index-BXG-pbr_.js +0 -375
  68. package/dist/assets/index-svYFaNuq.css +0 -1
  69. package/dist/chunk-KZXYQYIU.js.map +0 -1
  70. package/src/components/editor/keyframeMove.test.ts +0 -101
  71. package/src/components/editor/keyframeMove.ts +0 -151
  72. /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
@@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
17
  import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
18
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
+ import { roundTo3 } from "../utils/rounding";
18
20
 
19
21
  interface CommitAnimatedPropertyDeps {
20
22
  selectedGsapAnimations: GsapAnimation[];
@@ -45,14 +47,22 @@ function pickBestAnimation(
45
47
  selector: string | null,
46
48
  property?: string,
47
49
  ): GsapAnimation | undefined {
48
- if (animations.length <= 1) return animations[0];
49
- const currentTime = usePlayerStore.getState().currentTime;
50
50
  const targetGroup = property ? classifyPropertyGroup(property) : undefined;
51
-
52
- // fallow-ignore-next-line complexity
53
- const scored = animations.map((a) => {
51
+ // Group-aware: never hand back a tween from a DIFFERENT property group. The old
52
+ // `animations.length <= 1` early return merged a rotation/3D edit into the element's
53
+ // only tween even when that was a `position` tween — contaminating it and leaving the
54
+ // new property with no clean keyframe baseline. When a target group is known, only
55
+ // same-group tweens are candidates; if none exist we return undefined and the caller
56
+ // creates a fresh same-group tween.
57
+ const candidates =
58
+ targetGroup !== undefined
59
+ ? animations.filter((a) => a.propertyGroup === targetGroup)
60
+ : animations;
61
+ if (candidates.length === 0) return undefined;
62
+ if (candidates.length === 1) return candidates[0];
63
+ const currentTime = usePlayerStore.getState().currentTime;
64
+ const scored = candidates.map((a) => {
54
65
  let score = 0;
55
- if (targetGroup && a.propertyGroup === targetGroup) score += 20;
56
66
  if (a.keyframes) score += 10;
57
67
  if (selector && a.targetSelector === selector) score += 5;
58
68
  else if (a.targetSelector.includes(",")) score -= 3;
@@ -196,14 +206,15 @@ async function commitKeyframeProps(
196
206
  iframe: HTMLIFrameElement | null,
197
207
  commit: Commit,
198
208
  ): Promise<void> {
199
- if (!anim.keyframes) {
209
+ const wasKeyframed = !!anim.keyframes;
210
+ if (!wasKeyframed) {
200
211
  await commit(
201
212
  selection,
202
213
  { type: "convert-to-keyframes", animationId: anim.id },
203
214
  { label: "Convert to keyframes", skipReload: true },
204
215
  );
205
216
  }
206
- const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
217
+ const ct = usePlayerStore.getState().currentTime;
207
218
  const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
208
219
  const properties: Record<string, number | string> = { ...runtimeProps, ...props };
209
220
 
@@ -216,6 +227,52 @@ async function commitKeyframeProps(
216
227
  backfillDefaults[property] = value;
217
228
  }
218
229
 
230
+ // Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it
231
+ // and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe.
232
+ // The add-keyframe below only writes WITHIN the existing range, so without this a
233
+ // depth edit past the tween end just overwrites the last keyframe (the bug: no new
234
+ // diamond appears at a playhead beyond the tween). Only for an already-keyframed
235
+ // tween — a freshly-converted set has no prior range worth remapping.
236
+ const kfs = anim.keyframes?.keyframes;
237
+ const ts = resolveTweenStart(anim);
238
+ const td = resolveTweenDuration(anim);
239
+ const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
240
+ const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
241
+ const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe;
242
+ if (willExtend && kfs && ts !== null) {
243
+ const newStart = Math.min(ct, ts);
244
+ const newEnd = Math.max(ct, ts + td);
245
+ const newDuration = Math.max(0.01, newEnd - newStart);
246
+ const remapped = kfs.map((kf) => {
247
+ const absTime = ts + (kf.percentage / 100) * td;
248
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
249
+ const p: Record<string, number | string> = { ...kf.properties };
250
+ for (const k of Object.keys(properties)) {
251
+ if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
252
+ }
253
+ return { percentage: newPct, properties: p };
254
+ });
255
+ remapped.push({
256
+ percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10,
257
+ properties,
258
+ });
259
+ remapped.sort((a, b) => a.percentage - b.percentage);
260
+ await commit(
261
+ selection,
262
+ {
263
+ type: "replace-with-keyframes",
264
+ animationId: anim.id,
265
+ targetSelector: anim.targetSelector,
266
+ position: roundTo3(newStart),
267
+ duration: roundTo3(newDuration),
268
+ keyframes: remapped,
269
+ },
270
+ { label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
271
+ );
272
+ return;
273
+ }
274
+
275
+ const pct = computeElementPercentage(ct, selection, anim);
219
276
  const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
220
277
  // Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
221
278
  // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
@@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
275
332
  // so the rejection doesn't escape as an uncaught promise, and bump the cache
276
333
  // so selectedGsapAnimations re-syncs and the user's next edit self-heals.
277
334
  try {
278
- // Existing static hold merge the props into the `set`, then auto-keyframe
279
- // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not).
335
+ // Animated element keyframe at the playhead, EXACTLY like manual drag /
336
+ // resize / rotate: if the picked anim is still a static `set`,
337
+ // commitKeyframeProps converts it to keyframes first, then writes the new
338
+ // value as a keyframe at the current time — so the 3D animates instead of
339
+ // holding a flat constant. This MUST come before the `set`-update path below,
340
+ // or a 3D `set` would short-circuit to an in-place update and the playhead
341
+ // keyframe would never land (the bug: scrolling depth on a keyframed element
342
+ // just changed the constant instead of dropping a keyframe).
343
+ if (elementHasKeyframes && anim) {
344
+ await commitKeyframeProps(
345
+ selection,
346
+ anim,
347
+ props,
348
+ propEntries,
349
+ primaryProp,
350
+ selector,
351
+ iframe,
352
+ gsapCommitMutation,
353
+ );
354
+ return;
355
+ }
356
+
357
+ // Existing static hold on a NON-animated element — merge the props into the
358
+ // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
280
359
  if (anim?.method === "set") {
281
360
  await commitSetProps(
282
361
  selection,
@@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
289
368
  return;
290
369
  }
291
370
 
292
- // Static element — persist as a `tl.set`, never keyframes (incl. the
293
- // no-animation case, which now creates a set instead of a keyframed tween).
371
+ // Static element (no keyframes anywhere) — persist as a `tl.set`, never
372
+ // keyframes (incl. the no-animation case, which creates a fresh set).
294
373
  if (!elementHasKeyframes) {
295
374
  await commitStaticSet(
296
375
  selection,
@@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
302
381
  return;
303
382
  }
304
383
 
305
- // Animated element write ALL props into ONE keyframe so a multi-axis cube
306
- // edit doesn't race into adjacent duplicates.
307
- if (!anim) {
308
- bumpGsapCache();
384
+ // Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D
385
+ // keyframe on an element that only has a position tween). Create a fresh
386
+ // same-group keyframed tween WITH a 0% baseline at the playhead, instead of
387
+ // contaminating a foreign-group tween. Mirror an existing keyframed tween's
388
+ // time range so the new group animates over the same span. The 0% baseline is
389
+ // an `_auto` endpoint so it tracks the nearest keyframe as you add more.
390
+ if (selector) {
391
+ const template = selectedGsapAnimations.find((a) => !!a.keyframes);
392
+ const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
393
+ const tDur = template ? resolveTweenDuration(template) || 1 : 1;
394
+ const ct = usePlayerStore.getState().currentTime;
395
+ const pct =
396
+ tDur > 0
397
+ ? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10))
398
+ : 0;
399
+ const newProps = Object.fromEntries(propEntries);
400
+ const keyframes =
401
+ pct <= 0.05
402
+ ? [{ percentage: 0, properties: newProps }]
403
+ : [
404
+ { percentage: 0, properties: { ...newProps, _auto: 1 } },
405
+ { percentage: pct, properties: newProps },
406
+ ];
407
+ await gsapCommitMutation(
408
+ selection,
409
+ {
410
+ type: "add-with-keyframes",
411
+ targetSelector: selector,
412
+ position: roundTo3(tStart),
413
+ duration: roundTo3(tDur),
414
+ keyframes,
415
+ },
416
+ { label: `Add ${primaryProp} keyframe`, softReload: true },
417
+ );
309
418
  return;
310
419
  }
311
- await commitKeyframeProps(
312
- selection,
313
- anim,
314
- props,
315
- propEntries,
316
- primaryProp,
317
- selector,
318
- iframe,
319
- gsapCommitMutation,
320
- );
420
+ bumpGsapCache();
321
421
  } catch {
322
422
  bumpGsapCache();
323
423
  }
@@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
11
+ import { trackStudioEvent } from "../utils/studioTelemetry";
11
12
 
12
13
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
13
14
  try {
@@ -117,6 +118,10 @@ interface UseAppHotkeysParams {
117
118
  onDeleteSelectedKeyframes: () => void;
118
119
  onAfterUndoRedo?: () => void;
119
120
  onToggleRecording?: () => void;
121
+ /** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
122
+ onGroupSelection?: () => void;
123
+ /** Ungroup the selected group wrapper (⌘⇧G). */
124
+ onUngroupSelection?: () => void;
120
125
  /** Active composition path — used to decide whether undo/redo must resync the SDK session. */
121
126
  activeCompPath?: string | null;
122
127
  /**
@@ -142,6 +147,8 @@ interface HotkeyCallbacks {
142
147
  onResetKeyframes: () => boolean;
143
148
  onDeleteSelectedKeyframes: () => void;
144
149
  onToggleRecording?: () => void;
150
+ onGroupSelection?: () => void;
151
+ onUngroupSelection?: () => void;
145
152
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
146
153
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
147
154
  showToast: (message: string, tone?: "error" | "info") => void;
@@ -152,36 +159,56 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
152
159
  !shouldIgnoreHistoryShortcut(event.target) &&
153
160
  handleUndoRedoKey(
154
161
  event,
155
- () => void cb.handleUndo(),
156
- () => void cb.handleRedo(),
162
+ () => {
163
+ trackStudioEvent("keyboard_shortcut", { action: "undo" });
164
+ void cb.handleUndo();
165
+ },
166
+ () => {
167
+ trackStudioEvent("keyboard_shortcut", { action: "redo" });
168
+ void cb.handleRedo();
169
+ },
157
170
  )
158
171
  )
159
172
  return true;
160
173
 
161
174
  if (event.key === "1") {
162
175
  event.preventDefault();
176
+ trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
163
177
  cb.leftSidebarRef.current?.selectTab("compositions");
164
178
  return true;
165
179
  }
166
180
  if (event.key === "2") {
167
181
  event.preventDefault();
182
+ trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
168
183
  cb.leftSidebarRef.current?.selectTab("assets");
169
184
  return true;
170
185
  }
171
186
 
187
+ if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
188
+ event.preventDefault();
189
+ if (event.shiftKey) cb.onUngroupSelection?.();
190
+ else cb.onGroupSelection?.();
191
+ return true;
192
+ }
193
+
172
194
  if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
173
195
  if (key === "c") {
174
- if (cb.handleCopy()) event.preventDefault();
196
+ if (cb.handleCopy()) {
197
+ event.preventDefault();
198
+ trackStudioEvent("keyboard_shortcut", { action: "copy" });
199
+ }
175
200
  return true;
176
201
  }
177
202
  if (key === "v") {
178
203
  event.preventDefault();
204
+ trackStudioEvent("keyboard_shortcut", { action: "paste" });
179
205
  void cb.handlePaste();
180
206
  return true;
181
207
  }
182
208
  if (key === "x") {
183
209
  if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
184
210
  event.preventDefault();
211
+ trackStudioEvent("keyboard_shortcut", { action: "cut" });
185
212
  void cb.handleCut();
186
213
  }
187
214
  return true;
@@ -310,6 +337,8 @@ export function useAppHotkeys({
310
337
  onDeleteSelectedKeyframes,
311
338
  onAfterUndoRedo,
312
339
  onToggleRecording,
340
+ onGroupSelection,
341
+ onUngroupSelection,
313
342
  activeCompPath,
314
343
  forceReloadSdkSession,
315
344
  }: UseAppHotkeysParams) {
@@ -403,6 +432,8 @@ export function useAppHotkeys({
403
432
  onResetKeyframes,
404
433
  onDeleteSelectedKeyframes,
405
434
  onToggleRecording,
435
+ onGroupSelection,
436
+ onUngroupSelection,
406
437
  leftSidebarRef,
407
438
  domEditSelectionRef,
408
439
  showToast,
@@ -1,4 +1,5 @@
1
1
  import { useCallback } from "react";
2
+ import { trackStudioEvent } from "../utils/studioTelemetry";
2
3
  import type { TimelineElement } from "../player";
3
4
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
4
5
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -12,6 +13,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
12
13
  import { useDomSelection } from "./useDomSelection";
13
14
  import { usePreviewInteraction } from "./usePreviewInteraction";
14
15
  import { useDomEditCommits } from "./useDomEditCommits";
16
+ import { useGroupCommits } from "./useGroupCommits";
15
17
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
16
18
  import { useGsapCacheVersion } from "./useGsapTweenCache";
17
19
  import { useDomEditWiring } from "./useDomEditWiring";
@@ -114,7 +116,10 @@ export function useDomEditSession({
114
116
  domEditSelection,
115
117
  domEditGroupSelections,
116
118
  domEditHoverSelection,
119
+ activeGroupElement,
117
120
  domEditSelectionRef,
121
+ domEditGroupSelectionsRef,
122
+ setActiveGroupElement,
118
123
  applyDomSelection,
119
124
  clearDomSelection,
120
125
  buildDomSelectionFromTarget,
@@ -279,6 +284,44 @@ export function useDomEditSession({
279
284
  : undefined,
280
285
  });
281
286
 
287
+ // ── Element groups (wrap selected elements in a data-hf-group div) ──
288
+
289
+ const { groupSelection, ungroupSelection } = useGroupCommits({
290
+ activeCompPath,
291
+ showToast,
292
+ writeProjectFile,
293
+ domEditSaveTimestampRef,
294
+ editHistory,
295
+ projectIdRef,
296
+ reloadPreview,
297
+ clearDomSelection,
298
+ forceReloadSdkSession,
299
+ });
300
+
301
+ const handleGroupSelection = useCallback(() => {
302
+ const group = domEditGroupSelectionsRef.current;
303
+ const single = domEditSelectionRef.current;
304
+ const members = group.length > 0 ? group : single ? [single] : [];
305
+ if (members.length < 2) {
306
+ showToast("Select at least 2 elements to group", "info");
307
+ return;
308
+ }
309
+ trackStudioEvent("group", { action: "create", count: members.length });
310
+ void groupSelection(members);
311
+ }, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
312
+
313
+ const handleUngroupSelection = useCallback(() => {
314
+ const sel = domEditSelectionRef.current;
315
+ if (!sel?.element.hasAttribute("data-hf-group")) {
316
+ showToast("Select a group to ungroup", "info");
317
+ return;
318
+ }
319
+ // Dissolving the group exits any drill-in (the wrapper is about to vanish).
320
+ trackStudioEvent("group", { action: "ungroup" });
321
+ setActiveGroupElement(null);
322
+ void ungroupSelection(sel);
323
+ }, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
324
+
282
325
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
283
326
 
284
327
  const {
@@ -360,6 +403,7 @@ export function useDomEditSession({
360
403
  resolveDomSelectionFromPreviewPoint,
361
404
  resolveAllDomSelectionsFromPreviewPoint,
362
405
  updateDomEditHoverSelection,
406
+ setActiveGroupElement,
363
407
  onClickToSource,
364
408
  });
365
409
 
@@ -435,6 +479,7 @@ export function useDomEditSession({
435
479
  domEditSelection,
436
480
  domEditGroupSelections,
437
481
  domEditHoverSelection,
482
+ activeGroupElement,
438
483
  agentModalOpen,
439
484
  agentModalAnchorPoint,
440
485
  copiedAgentPrompt,
@@ -467,6 +512,9 @@ export function useDomEditSession({
467
512
  handleBlockedDomMove,
468
513
  handleDomManualDragStart,
469
514
  handleDomEditElementDelete,
515
+ handleGroupSelection,
516
+ handleUngroupSelection,
517
+ setActiveGroupElement,
470
518
  buildDomSelectionFromTarget,
471
519
  buildDomSelectionForTimelineElement,
472
520
  updateDomEditHoverSelection,
@@ -48,13 +48,16 @@ export interface UseDomSelectionReturn {
48
48
  domEditSelection: DomEditSelection | null;
49
49
  domEditGroupSelections: DomEditSelection[];
50
50
  domEditHoverSelection: DomEditSelection | null;
51
+ activeGroupElement: HTMLElement | null;
51
52
  // Refs
52
53
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
53
54
  domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
54
55
  domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
56
+ activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
55
57
  // State setters (needed by useDomEditSession for agent-prompt reset flows)
56
58
  setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
57
59
  setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
60
+ setActiveGroupElement: (el: HTMLElement | null) => void;
58
61
  // Callbacks
59
62
  applyDomSelection: (
60
63
  selection: DomEditSelection | null,
@@ -67,12 +70,20 @@ export interface UseDomSelectionReturn {
67
70
  clearDomSelection: () => void;
68
71
  buildDomSelectionFromTarget: (
69
72
  target: HTMLElement,
70
- options?: { preferClipAncestor?: boolean },
73
+ options?: {
74
+ preferClipAncestor?: boolean;
75
+ skipSourceProbe?: boolean;
76
+ activeGroupElement?: HTMLElement | null;
77
+ },
71
78
  ) => Promise<DomEditSelection | null>;
72
79
  resolveDomSelectionFromPreviewPoint: (
73
80
  clientX: number,
74
81
  clientY: number,
75
- options?: { preferClipAncestor?: boolean },
82
+ options?: {
83
+ preferClipAncestor?: boolean;
84
+ skipSourceProbe?: boolean;
85
+ activeGroupElement?: HTMLElement | null;
86
+ },
76
87
  ) => Promise<DomEditSelection | null>;
77
88
  resolveAllDomSelectionsFromPreviewPoint: (
78
89
  clientX: number,
@@ -110,17 +121,21 @@ export function useDomSelection({
110
121
  const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
111
122
  const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
112
123
  const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
124
+ // The data-hf-group wrapper the user has drilled into (null = top level).
125
+ const [activeGroupElement, setActiveGroupElementState] = useState<HTMLElement | null>(null);
113
126
 
114
127
  // ── Refs ──
115
128
 
116
129
  const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
117
130
  const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
118
131
  const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
132
+ const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
119
133
 
120
134
  // Keep refs in sync with state
121
135
  domEditSelectionRef.current = domEditSelection;
122
136
  domEditGroupSelectionsRef.current = domEditGroupSelections;
123
137
  domEditHoverSelectionRef.current = domEditHoverSelection;
138
+ activeGroupElementRef.current = activeGroupElement;
124
139
 
125
140
  // ── Callbacks ──
126
141
 
@@ -178,6 +193,14 @@ export function useDomSelection({
178
193
  setDomEditSelection(nextSelection);
179
194
  setDomEditGroupSelections(nextGroup);
180
195
 
196
+ // Selecting something outside the drilled-into group exits the drill-in, so
197
+ // a later click on the group selects it as a unit again (non-sticky drill-in).
198
+ const activeGroup = activeGroupElementRef.current;
199
+ if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) {
200
+ activeGroupElementRef.current = null;
201
+ setActiveGroupElementState(null);
202
+ }
203
+
181
204
  if (nextSelection) {
182
205
  if (options?.revealPanel !== false) {
183
206
  setRightCollapsed(false);
@@ -203,16 +226,36 @@ export function useDomSelection({
203
226
  applyDomSelection(null, { revealPanel: false });
204
227
  }, [applyDomSelection]);
205
228
 
229
+ // Drill into / out of a group. Changing scope clears the current selection so
230
+ // the user isn't left with an out-of-scope element selected.
231
+ const setActiveGroupElement = useCallback(
232
+ (el: HTMLElement | null) => {
233
+ setActiveGroupElementState(el);
234
+ applyDomSelection(null, { revealPanel: false });
235
+ },
236
+ [applyDomSelection],
237
+ );
238
+
206
239
  const buildDomSelectionFromTarget = useCallback(
207
240
  (
208
241
  target: HTMLElement,
209
- options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
242
+ options?: {
243
+ preferClipAncestor?: boolean;
244
+ skipSourceProbe?: boolean;
245
+ // Override the drill-in scope (used by canvas double-click to resolve the
246
+ // child inside a group before the activeGroupElement state has re-rendered).
247
+ activeGroupElement?: HTMLElement | null;
248
+ },
210
249
  ) => {
211
250
  return resolveDomEditSelection(target, {
212
251
  activeCompositionPath: activeCompPath,
213
252
  isMasterView,
214
253
  preferClipAncestor: options?.preferClipAncestor,
215
254
  skipSourceProbe: options?.skipSourceProbe,
255
+ activeGroupElement:
256
+ options && "activeGroupElement" in options
257
+ ? options.activeGroupElement
258
+ : activeGroupElementRef.current,
216
259
  projectId,
217
260
  });
218
261
  },
@@ -224,7 +267,11 @@ export function useDomSelection({
224
267
  async (
225
268
  clientX: number,
226
269
  clientY: number,
227
- options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
270
+ options?: {
271
+ preferClipAncestor?: boolean;
272
+ skipSourceProbe?: boolean;
273
+ activeGroupElement?: HTMLElement | null;
274
+ },
228
275
  ) => {
229
276
  const iframe = previewIframeRef.current;
230
277
  if (!iframe || captionEditMode) return null;
@@ -235,10 +282,19 @@ export function useDomSelection({
235
282
  }
236
283
  const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
237
284
  if (!target) return null;
238
- return buildDomSelectionFromTarget(target, {
239
- preferClipAncestor: options?.preferClipAncestor,
240
- skipSourceProbe: options?.skipSourceProbe,
241
- });
285
+ return buildDomSelectionFromTarget(
286
+ target,
287
+ options && "activeGroupElement" in options
288
+ ? {
289
+ preferClipAncestor: options.preferClipAncestor,
290
+ skipSourceProbe: options.skipSourceProbe,
291
+ activeGroupElement: options.activeGroupElement,
292
+ }
293
+ : {
294
+ preferClipAncestor: options?.preferClipAncestor,
295
+ skipSourceProbe: options?.skipSourceProbe,
296
+ },
297
+ );
242
298
  },
243
299
  [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
244
300
  );
@@ -445,7 +501,12 @@ export function useDomSelection({
445
501
  if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
446
502
  }
447
503
  } else {
448
- nextGroup = selections;
504
+ // Dedupe by target: under select-as-unit several marquee'd members collapse
505
+ // to the same group, which must count as one selection, not many duplicates.
506
+ nextGroup = [];
507
+ for (const s of selections) {
508
+ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
509
+ }
449
510
  }
450
511
  const nextSelection = additive && current ? current : selections[0];
451
512
  domEditSelectionRef.current = nextSelection;
@@ -478,13 +539,16 @@ export function useDomSelection({
478
539
  domEditSelection,
479
540
  domEditGroupSelections,
480
541
  domEditHoverSelection,
542
+ activeGroupElement,
481
543
  // Refs
482
544
  domEditSelectionRef,
483
545
  domEditGroupSelectionsRef,
484
546
  domEditHoverSelectionRef,
547
+ activeGroupElementRef,
485
548
  // State setters
486
549
  setDomEditSelection,
487
550
  setDomEditGroupSelections,
551
+ setActiveGroupElement,
488
552
  // Callbacks
489
553
  applyDomSelection,
490
554
  clearDomSelection,
@@ -167,4 +167,39 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
167
167
  expect(kfs[1].percentage).toBe(100);
168
168
  expect(kfs[1].auto).toBeUndefined();
169
169
  });
170
+
171
+ it("playhead AT the set (t <= setStart) drops a single 0% keyframe, not a no-op", async () => {
172
+ // Regression: enabling keyframes on a `gsap.set` element at t=0 (set start 0)
173
+ // returned early (`t <= setStart`) → nothing created. Must give a 0% keyframe.
174
+ let committed: Record<string, unknown> | undefined;
175
+ const session = {
176
+ commitMutation: async (mutation: Record<string, unknown>) => {
177
+ committed = mutation;
178
+ },
179
+ } as unknown as EnableKeyframesSession;
180
+ const sel = {
181
+ id: "box",
182
+ selector: "#box",
183
+ sourceFile: "index.html",
184
+ element: { isConnected: true } as unknown as HTMLElement,
185
+ } as unknown as DomEditSelection;
186
+ const iframe = {
187
+ contentWindow: { gsap: { getProperty: () => -1091 } },
188
+ } as unknown as HTMLIFrameElement;
189
+ const setAnim = anim({
190
+ id: "#box-set-0-position",
191
+ targetSelector: "#box",
192
+ method: "set",
193
+ global: true,
194
+ resolvedStart: 0,
195
+ properties: { x: -1091, y: 280 },
196
+ });
197
+
198
+ await promoteSetToKeyframes(session, sel, setAnim, 0, iframe);
199
+
200
+ const kfs = committed?.keyframes as Array<{ percentage: number }>;
201
+ expect(committed?.type).toBe("replace-with-keyframes");
202
+ expect(kfs).toHaveLength(1);
203
+ expect(kfs[0].percentage).toBe(0);
204
+ });
170
205
  });
@@ -253,7 +253,35 @@ export async function promoteSetToKeyframes(
253
253
  ): Promise<void> {
254
254
  const selector = selectorFromSelection(sel);
255
255
  const setStart = resolveTweenStart(setAnim) ?? 0;
256
- if (!selector || !session.commitMutation || t <= setStart) return;
256
+ if (!selector || !session.commitMutation) return;
257
+ // Playhead at or before the set → there's no forward range to promote into.
258
+ // Instead of doing nothing (which read as "can't add a keyframe at 0"), replace
259
+ // the set with a single keyframe at the playhead holding its value, matching the
260
+ // no-animation branch: one diamond the user can build motion from.
261
+ if (t <= setStart) {
262
+ const position = readElementPosition(iframe, sel, setAnim);
263
+ if (Object.keys(position).length === 0) {
264
+ for (const key of Object.keys(setAnim.properties ?? {})) {
265
+ const held = setAnim.properties?.[key];
266
+ if (typeof held === "number") position[key] = held;
267
+ }
268
+ }
269
+ if (Object.keys(position).length === 0) return;
270
+ const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
271
+ await session.commitMutation(
272
+ {
273
+ type: "replace-with-keyframes",
274
+ animationId: setAnim.id,
275
+ targetSelector: selector,
276
+ position: roundTo3(range.start),
277
+ duration: roundTo3(range.duration),
278
+ keyframes: [{ percentage: 0, properties: position }],
279
+ ease: setAnim.ease,
280
+ },
281
+ { label: "Enable keyframes", softReload: true },
282
+ );
283
+ return;
284
+ }
257
285
  const endPosition = readElementPosition(iframe, sel, setAnim);
258
286
  if (Object.keys(endPosition).length === 0) return;
259
287
  const startPosition: Record<string, number> = {};