@hyperframes/studio 0.7.15 → 0.7.17

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 (65) hide show
  1. package/dist/assets/{index-CTXExGxO.js → index-BCAdHPz_.js} +1 -1
  2. package/dist/assets/{index-Db9KG66z.js → index-BDx28x9R.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/index-hiqLDAtz.js +375 -0
  5. package/dist/{chunk-UUEDZ4UJ.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-NWCBED4R.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +18 -1
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3157 -2808
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  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 +3 -0
  31. package/src/components/editor/manualOffsetDrag.ts +23 -2
  32. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  33. package/src/components/editor/propertyPanelHelpers.ts +2 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  35. package/src/components/editor/useMotionPathData.ts +47 -1
  36. package/src/contexts/DomEditContext.tsx +16 -0
  37. package/src/contexts/TimelineEditContext.tsx +0 -1
  38. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  39. package/src/hooks/gsapTweenSynth.ts +64 -0
  40. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  41. package/src/hooks/useAppHotkeys.ts +34 -3
  42. package/src/hooks/useDomEditSession.ts +48 -0
  43. package/src/hooks/useDomSelection.ts +73 -9
  44. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  45. package/src/hooks/useEnableKeyframes.ts +29 -1
  46. package/src/hooks/useGroupCommits.ts +188 -0
  47. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  48. package/src/hooks/useGsapTweenCache.ts +56 -72
  49. package/src/hooks/usePreviewInteraction.ts +60 -2
  50. package/src/player/components/ShortcutsPanel.tsx +2 -0
  51. package/src/player/components/Timeline.tsx +0 -15
  52. package/src/player/components/TimelineCanvas.tsx +0 -13
  53. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  54. package/src/player/components/timelineCallbacks.ts +0 -1
  55. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  56. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  57. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  58. package/src/player/store/playerStore.ts +20 -0
  59. package/src/utils/studioPreviewHelpers.ts +37 -0
  60. package/dist/assets/index-B7VWFKy1.js +0 -375
  61. package/dist/assets/index-svYFaNuq.css +0 -1
  62. package/dist/chunk-UUEDZ4UJ.js.map +0 -1
  63. package/src/components/editor/keyframeMove.test.ts +0 -101
  64. package/src/components/editor/keyframeMove.ts +0 -151
  65. /package/dist/{domEditingLayers-NWCBED4R.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
@@ -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> = {};
@@ -0,0 +1,188 @@
1
+ import { useCallback } from "react";
2
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
+ import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
4
+ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
5
+ import type { EditHistoryKind } from "../utils/editHistory";
6
+
7
+ interface RecordEditInput {
8
+ label: string;
9
+ kind: EditHistoryKind;
10
+ coalesceKey?: string;
11
+ files: Record<string, { before: string; after: string }>;
12
+ }
13
+
14
+ interface UseGroupCommitsParams {
15
+ activeCompPath: string | null;
16
+ showToast: (message: string, tone?: "error" | "info") => void;
17
+ writeProjectFile: (path: string, content: string) => Promise<void>;
18
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
19
+ editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
20
+ projectIdRef: React.MutableRefObject<string | null>;
21
+ reloadPreview: () => void;
22
+ clearDomSelection: () => void;
23
+ /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
24
+ * structure the in-memory doc doesn't know about). */
25
+ forceReloadSdkSession?: () => void;
26
+ }
27
+
28
+ interface PatchTarget {
29
+ id?: string | null;
30
+ hfId?: string;
31
+ selector?: string;
32
+ selectorIndex?: number;
33
+ }
34
+
35
+ interface GroupGeometry {
36
+ bbox: { left: number; top: number; width: number; height: number };
37
+ targets: PatchTarget[];
38
+ rebases: Array<{ target: PatchTarget; left: number; top: number }>;
39
+ }
40
+
41
+ // Wrapper sits at the members' bounding box top-left; each member is rebased so
42
+ // its absolute position is unchanged. offsetLeft/Top are layout coordinates in
43
+ // composition space (transforms excluded), exactly the space the rebase formula
44
+ // `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
45
+ // transform deltas and stay correct without adjustment.
46
+ function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
47
+ const boxes = members.map((m) => ({
48
+ target: buildDomEditPatchTarget(m),
49
+ left: m.element.offsetLeft,
50
+ top: m.element.offsetTop,
51
+ right: m.element.offsetLeft + m.element.offsetWidth,
52
+ bottom: m.element.offsetTop + m.element.offsetHeight,
53
+ }));
54
+ const left = Math.min(...boxes.map((b) => b.left));
55
+ const top = Math.min(...boxes.map((b) => b.top));
56
+ const width = Math.max(...boxes.map((b) => b.right)) - left;
57
+ const height = Math.max(...boxes.map((b) => b.bottom)) - top;
58
+ return {
59
+ bbox: { left, top, width, height },
60
+ targets: boxes.map((b) => b.target),
61
+ rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })),
62
+ };
63
+ }
64
+
65
+ // Shared read → mutate-route → save-with-history → reload pipeline for both
66
+ // wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
67
+ // useElementLifecycleOps (delete). Returns the route's JSON, or throws.
68
+ async function commitStructuralMutation(
69
+ pid: string,
70
+ targetPath: string,
71
+ route: "wrap-elements" | "unwrap-elements",
72
+ body: unknown,
73
+ label: string,
74
+ deps: Pick<
75
+ UseGroupCommitsParams,
76
+ | "writeProjectFile"
77
+ | "editHistory"
78
+ | "domEditSaveTimestampRef"
79
+ | "clearDomSelection"
80
+ | "forceReloadSdkSession"
81
+ | "reloadPreview"
82
+ >,
83
+ ): Promise<{ content?: string; groupId?: string }> {
84
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
85
+ if (!response.ok) {
86
+ throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
87
+ }
88
+ const data = (await response.json()) as { content?: string };
89
+ const originalContent = data.content;
90
+ if (typeof originalContent !== "string") {
91
+ throw new Error(`Missing file contents for ${targetPath}`);
92
+ }
93
+
94
+ deps.domEditSaveTimestampRef.current = Date.now();
95
+ const mutateResponse = await fetch(
96
+ `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
97
+ {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify(body),
101
+ },
102
+ );
103
+ if (!mutateResponse.ok) {
104
+ const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
105
+ throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
106
+ }
107
+ const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
108
+ const patchedContent =
109
+ typeof mutateData.content === "string" ? mutateData.content : originalContent;
110
+
111
+ await saveProjectFilesWithHistory({
112
+ projectId: pid,
113
+ label,
114
+ kind: "manual",
115
+ files: { [targetPath]: patchedContent },
116
+ readFile: async () => originalContent,
117
+ writeFile: deps.writeProjectFile,
118
+ recordEdit: deps.editHistory.recordEdit,
119
+ });
120
+ deps.clearDomSelection();
121
+ deps.forceReloadSdkSession?.();
122
+ deps.reloadPreview();
123
+ return mutateData;
124
+ }
125
+
126
+ export function useGroupCommits(params: UseGroupCommitsParams) {
127
+ const { activeCompPath, showToast, projectIdRef } = params;
128
+
129
+ const groupSelection = useCallback(
130
+ async (members: DomEditSelection[]): Promise<string | null> => {
131
+ const pid = projectIdRef.current;
132
+ if (!pid || members.length === 0) return null;
133
+
134
+ // All members must live in the same source file — the wrapper is one node
135
+ // in one document. (Cross-file grouping is out of scope.)
136
+ const targetPath = members[0].sourceFile || activeCompPath || "index.html";
137
+ if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) {
138
+ showToast("Can't group elements from different files", "error");
139
+ return null;
140
+ }
141
+
142
+ // Auto-name "Group N" by the count of existing groups in the document.
143
+ const doc = members[0].element.ownerDocument;
144
+ const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
145
+ const { bbox, targets, rebases } = computeGroupGeometry(members);
146
+
147
+ try {
148
+ const data = await commitStructuralMutation(
149
+ pid,
150
+ targetPath,
151
+ "wrap-elements",
152
+ { targets, groupId, bbox, rebases },
153
+ "Group elements",
154
+ params,
155
+ );
156
+ return data.groupId ?? groupId;
157
+ } catch (error) {
158
+ showToast(error instanceof Error ? error.message : "Failed to group elements", "error");
159
+ return null;
160
+ }
161
+ },
162
+ [activeCompPath, projectIdRef, showToast, params],
163
+ );
164
+
165
+ const ungroupSelection = useCallback(
166
+ async (group: DomEditSelection): Promise<void> => {
167
+ const pid = projectIdRef.current;
168
+ if (!pid) return;
169
+ const targetPath = group.sourceFile || activeCompPath || "index.html";
170
+
171
+ try {
172
+ await commitStructuralMutation(
173
+ pid,
174
+ targetPath,
175
+ "unwrap-elements",
176
+ { target: buildDomEditPatchTarget(group) },
177
+ "Ungroup elements",
178
+ params,
179
+ );
180
+ } catch (error) {
181
+ showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
182
+ }
183
+ },
184
+ [activeCompPath, projectIdRef, showToast, params],
185
+ );
186
+
187
+ return { groupSelection, ungroupSelection };
188
+ }
@@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { usePlayerStore } from "../player";
4
4
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
6
 
6
7
  /**
7
8
  * Thin useCallback wrappers that guard on `domEditSelection` before
@@ -136,6 +137,7 @@ export function useGsapSelectionHandlers({
136
137
  (targetSelector: string) => {
137
138
  const sel = domEditSelection ?? lastSelectionRef.current;
138
139
  if (!sel) return;
140
+ trackStudioEvent("keyframe", { action: "delete_all" });
139
141
  deleteAllForSelector(sel, targetSelector);
140
142
  },
141
143
  [domEditSelection, deleteAllForSelector],
@@ -206,6 +208,7 @@ export function useGsapSelectionHandlers({
206
208
  ) => {
207
209
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
208
210
  if (!sel) return;
211
+ trackStudioEvent("keyframe", { action: "add", property });
209
212
  addKeyframe(sel, animId, percentage, property, value);
210
213
  },
211
214
  [domEditSelection, addKeyframe],
@@ -224,6 +227,7 @@ export function useGsapSelectionHandlers({
224
227
  (animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
225
228
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
226
229
  if (!sel) return;
230
+ trackStudioEvent("keyframe", { action: "remove" });
227
231
  removeKeyframe(sel, animId, percentage);
228
232
  },
229
233
  [domEditSelection, removeKeyframe],