@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
@@ -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],
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
2
  import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
- import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
4
3
  import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
5
4
  import { usePlayerStore } from "../player/store/playerStore";
6
5
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
@@ -8,63 +7,8 @@ import {
8
7
  clearKeyframeCacheForElement,
9
8
  clearKeyframeCacheForFile,
10
9
  } from "./gsapKeyframeCacheHelpers";
11
- import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
12
-
13
- function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
14
- const byPct = new Map<number, GsapPercentageKeyframe>();
15
- for (const kf of keyframes) {
16
- const existing = byPct.get(kf.percentage);
17
- if (existing) {
18
- existing.properties = { ...existing.properties, ...kf.properties };
19
- if (kf.ease) existing.ease = kf.ease;
20
- } else {
21
- byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
22
- }
23
- }
24
- return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
25
- }
26
-
27
- // fallow-ignore-next-line complexity
28
- function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
29
- if (anim.method === "set") {
30
- // A `set` is a STATIC HOLD — a value applied at one point, not an animated
31
- // keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
32
- // phantom diamond for a value that doesn't animate. This holds for a base
33
- // `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
34
- // path with the runtime scan, which already skips every zero-duration set.
35
- return null;
36
- }
37
- const toProps = anim.properties;
38
- const fromProps = anim.fromProperties;
39
- if (!toProps || Object.keys(toProps).length === 0) return null;
40
-
41
- const startProps: Record<string, number | string> = {};
42
- const endProps: Record<string, number | string> = {};
43
-
44
- if (anim.method === "from") {
45
- for (const [k, v] of Object.entries(toProps)) {
46
- startProps[k] = v;
47
- endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
48
- }
49
- } else if (anim.method === "fromTo" && fromProps) {
50
- Object.assign(startProps, fromProps);
51
- Object.assign(endProps, toProps);
52
- } else {
53
- for (const [k, v] of Object.entries(toProps)) {
54
- startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
55
- endProps[k] = v;
56
- }
57
- }
58
-
59
- return {
60
- format: "percentage",
61
- keyframes: [
62
- { percentage: 0, properties: startProps },
63
- { percentage: 100, properties: endProps },
64
- ],
65
- ...(anim.ease ? { ease: anim.ease } : {}),
66
- };
67
- }
10
+ import { toAbsoluteTime } from "./gsapShared";
11
+ import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
68
12
 
69
13
  function extractIdFromSelector(selector: string): string | null {
70
14
  const match = selector.match(/^#([\w-]+)/);
@@ -176,6 +120,37 @@ export async function fetchParsedAnimations(
176
120
  }
177
121
  }
178
122
 
123
+ /**
124
+ * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
125
+ * inside a scene) aren't timeline clips themselves — they're derived at expand time
126
+ * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
127
+ * and clip-relative keyframe percentages blew past 100% (rendering off the clip).
128
+ * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
129
+ * data-composition-src is stripped in the rendered DOM, so we can't query it).
130
+ */
131
+ function resolveClipTimingBasis(
132
+ elementId: string,
133
+ sourceFile: string,
134
+ elements: ReadonlyArray<{
135
+ domId?: string;
136
+ key?: string;
137
+ id: string;
138
+ start: number;
139
+ duration: number;
140
+ }>,
141
+ domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
142
+ ): { elStart: number; elDuration: number } {
143
+ const direct = elements.find(
144
+ (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
145
+ );
146
+ if (direct) return { elStart: direct.start, elDuration: direct.duration };
147
+ const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
148
+ const host = hostId
149
+ ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
150
+ : undefined;
151
+ return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
152
+ }
153
+
179
154
  export function useGsapAnimationsForElement(
180
155
  projectId: string | null,
181
156
  sourceFile: string,
@@ -192,6 +167,11 @@ export function useGsapAnimationsForElement(
192
167
  const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
193
168
  const lastFetchKeyRef = useRef("");
194
169
  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
170
+ // Re-run the per-element cache populate when sub-comp DOM children appear, so a
171
+ // sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
172
+ const domClipChildrenKey = usePlayerStore((s) =>
173
+ s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
174
+ );
195
175
 
196
176
  useEffect(() => {
197
177
  const targetKey = target?.id ?? target?.selector ?? "";
@@ -351,12 +331,13 @@ export function useGsapAnimationsForElement(
351
331
 
352
332
  // Resolve the element's time range from the player store so we can
353
333
  // convert tween-relative keyframe percentages to clip-relative ones.
354
- const { elements } = usePlayerStore.getState();
355
- const timelineEl = elements.find(
356
- (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
334
+ const { elements, domClipChildren } = usePlayerStore.getState();
335
+ const { elStart, elDuration } = resolveClipTimingBasis(
336
+ elementId,
337
+ sourceFile,
338
+ elements,
339
+ domClipChildren,
357
340
  );
358
- const elStart = timelineEl?.start ?? 0;
359
- const elDuration = timelineEl?.duration ?? 1;
360
341
 
361
342
  const allKeyframes: Array<
362
343
  GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
@@ -419,7 +400,8 @@ export function useGsapAnimationsForElement(
419
400
  // PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
420
401
  // so write a duplicate entry under the bare key for cross-component lookups.
421
402
  setKeyframeCache(elementId, merged);
422
- }, [elementId, sourceFile, animations]);
403
+ // eslint-disable-next-line react-hooks/exhaustive-deps
404
+ }, [elementId, sourceFile, animations, domClipChildrenKey]);
423
405
 
424
406
  return { animations, multipleTimelines, unsupportedTimelinePattern };
425
407
  }
@@ -442,13 +424,19 @@ export function usePopulateKeyframeCacheForFile(
442
424
  iframeRef?: React.RefObject<HTMLIFrameElement | null>,
443
425
  ): void {
444
426
  const elementCount = usePlayerStore((s) => s.elements.length);
427
+ // Re-run when sub-comp DOM children appear (they supply the host bounds the
428
+ // clip-relative keyframe percentages are computed against; without this the
429
+ // cache is computed once before they exist and the percentages stay wrong).
430
+ const domClipChildrenKey = usePlayerStore((s) =>
431
+ s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
432
+ );
445
433
  const lastFetchKeyRef = useRef("");
446
434
 
447
435
  const runtimeScanDoneRef = useRef("");
448
436
  const astFetchDoneRef = useRef("");
449
437
 
450
438
  useEffect(() => {
451
- const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
439
+ const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
452
440
  if (fetchKey === lastFetchKeyRef.current) return;
453
441
  lastFetchKeyRef.current = fetchKey;
454
442
  runtimeScanDoneRef.current = "";
@@ -461,7 +449,7 @@ export function usePopulateKeyframeCacheForFile(
461
449
  if (!parsed) return;
462
450
  const { setKeyframeCache } = usePlayerStore.getState();
463
451
  clearKeyframeCacheForFile(sf);
464
- const { elements } = usePlayerStore.getState();
452
+ const { elements, domClipChildren } = usePlayerStore.getState();
465
453
  const doc = iframeRef?.current?.contentDocument;
466
454
  const mergedByElement = new Map<string, GsapKeyframesData>();
467
455
  for (const anim of parsed.animations) {
@@ -482,11 +470,7 @@ export function usePopulateKeyframeCacheForFile(
482
470
  // Attribute the tween to every element it animates (handles class /
483
471
  // group / descendant selectors, not just `#id`).
484
472
  for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
485
- const timelineEl = elements.find(
486
- (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
487
- );
488
- const elStart = timelineEl?.start ?? 0;
489
- const elDuration = timelineEl?.duration ?? 1;
473
+ const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
490
474
  const clipKeyframes = kfData.keyframes.map((kf) => {
491
475
  const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
492
476
  // 0.001% precision (matching useGsapAnimationsForElement above) so a
@@ -524,7 +508,7 @@ export function usePopulateKeyframeCacheForFile(
524
508
  // iframeRef is read for DOM selector resolution but intentionally not a dep
525
509
  // (it's a stable ref; the separate runtime-scan effect owns iframe timing).
526
510
  // eslint-disable-next-line react-hooks/exhaustive-deps
527
- }, [projectId, sourceFile, version, elementCount]);
511
+ }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
528
512
 
529
513
  // Separate effect for runtime keyframe discovery — polls until the iframe
530
514
  // has loaded GSAP timelines, independent of the AST fetch lifecycle.
@@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player";
3
3
  import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
4
4
  import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
5
5
  import { type DomEditSelection } from "../components/editor/domEditing";
6
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
7
 
7
8
  // ── Types ──
8
9
 
@@ -20,13 +21,19 @@ export interface UsePreviewInteractionParams {
20
21
  resolveDomSelectionFromPreviewPoint: (
21
22
  clientX: number,
22
23
  clientY: number,
23
- options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
24
+ options?: {
25
+ preferClipAncestor?: boolean;
26
+ skipSourceProbe?: boolean;
27
+ activeGroupElement?: HTMLElement | null;
28
+ },
24
29
  ) => Promise<DomEditSelection | null>;
25
30
  resolveAllDomSelectionsFromPreviewPoint: (
26
31
  clientX: number,
27
32
  clientY: number,
28
33
  ) => Promise<DomEditSelection[]>;
29
34
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
35
+ /** Drill into a group (double-click on the canvas) so its children become selectable. */
36
+ setActiveGroupElement: (el: HTMLElement | null) => void;
30
37
 
31
38
  onClickToSource?: (selection: DomEditSelection) => void;
32
39
  }
@@ -41,6 +48,12 @@ interface ClickCycleState {
41
48
 
42
49
  const CYCLE_RADIUS_PX = 6;
43
50
  const CYCLE_WINDOW_MS = 600;
51
+ // Manual double-click window. `e.detail` can't be trusted here: the first click
52
+ // selects the group and re-renders the overlay, so the second click lands on a
53
+ // fresh element and the browser's native click-counter resets to 1 — drill-in
54
+ // (which keyed off `e.detail >= 2`) never fired. We track time+position instead.
55
+ const DOUBLE_CLICK_MS = 400;
56
+ const DOUBLE_CLICK_RADIUS_PX = 6;
44
57
 
45
58
  // ── Hook ──
46
59
 
@@ -53,15 +66,47 @@ export function usePreviewInteraction({
53
66
  resolveDomSelectionFromPreviewPoint,
54
67
  resolveAllDomSelectionsFromPreviewPoint,
55
68
  updateDomEditHoverSelection,
69
+ setActiveGroupElement,
56
70
  onClickToSource,
57
71
  }: UsePreviewInteractionParams) {
58
72
  const cycleRef = useRef<ClickCycleState | null>(null);
73
+ const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);
59
74
 
60
75
  const handlePreviewCanvasMouseDown = useCallback(
61
76
  // fallow-ignore-next-line complexity
62
77
  async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
63
78
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
64
79
 
80
+ // Manual double-click detection (see DOUBLE_CLICK_MS): the first click
81
+ // re-renders the overlay so `e.detail` never reaches 2 on the canvas.
82
+ const downTs = Date.now();
83
+ const lastDown = lastDownRef.current;
84
+ const isDoubleClick =
85
+ e.detail >= 2 ||
86
+ (lastDown != null &&
87
+ downTs - lastDown.t < DOUBLE_CLICK_MS &&
88
+ Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
89
+ lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };
90
+
91
+ // Double-click a group → drill into it and select the child under the
92
+ // pointer (resolve with the group as the explicit drill-in scope, since the
93
+ // activeGroupElement state hasn't re-rendered yet within this handler).
94
+ if (isDoubleClick && !e.shiftKey) {
95
+ const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
96
+ if (hit?.element.hasAttribute("data-hf-group")) {
97
+ e.preventDefault();
98
+ e.stopPropagation();
99
+ cycleRef.current = null;
100
+ trackStudioEvent("group", { action: "drill_in" });
101
+ setActiveGroupElement(hit.element);
102
+ const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
103
+ activeGroupElement: hit.element,
104
+ });
105
+ applyDomSelection(child ?? hit);
106
+ return;
107
+ }
108
+ }
109
+
65
110
  const now = Date.now();
66
111
  const prev = cycleRef.current;
67
112
  const dx = prev ? e.clientX - prev.x : Infinity;
@@ -96,9 +141,21 @@ export function usePreviewInteraction({
96
141
  }
97
142
 
98
143
  // Fresh click — resolve topmost element
99
- const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
144
+ let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
100
145
  preferClipAncestor: options?.preferClipAncestor ?? false,
101
146
  });
147
+ // A null result while drilled into a group means the click landed OUTSIDE that
148
+ // group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and
149
+ // re-resolve at the top level so this click selects whatever's there (or the
150
+ // group as a unit). Without this, a stale drill-in keeps selecting children and
151
+ // the "first click selects the group" expectation breaks.
152
+ if (!nextSelection) {
153
+ setActiveGroupElement(null);
154
+ nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
155
+ preferClipAncestor: options?.preferClipAncestor ?? false,
156
+ activeGroupElement: null,
157
+ });
158
+ }
102
159
  if (!nextSelection) {
103
160
  cycleRef.current = null;
104
161
  applyDomSelection(null, { revealPanel: false });
@@ -125,6 +182,7 @@ export function usePreviewInteraction({
125
182
  onClickToSource,
126
183
  resolveAllDomSelectionsFromPreviewPoint,
127
184
  resolveDomSelectionFromPreviewPoint,
185
+ setActiveGroupElement,
128
186
  ],
129
187
  );
130
188
 
@@ -36,6 +36,8 @@ const SHORTCUT_SECTIONS = [
36
36
  { key: "⌘V", label: "Paste element" },
37
37
  { key: "⌘X", label: "Cut element" },
38
38
  { key: "S", label: "Split clip at playhead" },
39
+ { key: "⌘G", label: "Group elements" },
40
+ { key: "⌘⇧G", label: "Ungroup" },
39
41
  { key: "Del", label: "Delete selected element" },
40
42
  ],
41
43
  },
@@ -20,7 +20,6 @@ import {
20
20
  type KeyframeDiamondContextMenuState,
21
21
  } from "./KeyframeDiamondContextMenu";
22
22
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
23
- import { snapKeyframePctToBeat } from "./timelineEditing";
24
23
  import { ClipContextMenu } from "./ClipContextMenu";
25
24
  import {
26
25
  GUTTER,
@@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({
87
86
  onDeleteKeyframe,
88
87
  onDeleteAllKeyframes,
89
88
  onChangeKeyframeEase,
90
- onMoveKeyframe,
91
89
  } = useResolvedTimelineEditCallbacks({
92
90
  onMoveElement: onMoveElementOverride,
93
91
  onResizeElement: onResizeElementOverride,
@@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({
481
479
  onShiftClickKeyframe={(elId, pct) => {
482
480
  toggleSelectedKeyframe(`${elId}:${pct}`);
483
481
  }}
484
- onDragKeyframe={(el, oldPct, newPct) => {
485
- onMoveKeyframe?.(el, oldPct, newPct);
486
- }}
487
- onSnapKeyframePct={(el, pct) =>
488
- snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps)
489
- }
490
- onPickKeyframeElement={(el) => {
491
- const elKey = el.key ?? el.id;
492
- if (selectedElementId !== elKey) {
493
- setSelectedElementId(elKey);
494
- onSelectElement?.(el);
495
- }
496
- }}
497
482
  onContextMenuKeyframe={(e, elId, pct) => {
498
483
  const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
499
484
  if (el) {
@@ -91,11 +91,6 @@ interface TimelineCanvasProps {
91
91
  currentTime: number;
92
92
  onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
93
93
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
94
- onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
95
- /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */
96
- onSnapKeyframePct?: (element: TimelineElement, pct: number) => number;
97
- /** Select the element when a keyframe drag starts (loads its GSAP session). */
98
- onPickKeyframeElement?: (element: TimelineElement) => void;
99
94
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
100
95
  onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
101
96
  beatAnalysis?: MusicBeatAnalysis | null;
@@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
143
138
  currentTime,
144
139
  onClickKeyframe,
145
140
  onShiftClickKeyframe,
146
- onDragKeyframe,
147
- onSnapKeyframePct,
148
- onPickKeyframeElement,
149
141
  onContextMenuKeyframe,
150
142
  onContextMenuClip,
151
143
  beatAnalysis,
@@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
446
438
  selectedKeyframes={selectedKeyframes}
447
439
  onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
448
440
  onShiftClickKeyframe={onShiftClickKeyframe}
449
- onDragKeyframe={(oldPct, newPct) =>
450
- onDragKeyframe?.(previewElement, oldPct, newPct)
451
- }
452
- snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct}
453
- onPickForDrag={() => onPickKeyframeElement?.(previewElement)}
454
441
  onContextMenuKeyframe={onContextMenuKeyframe}
455
442
  />
456
443
  )}