@hyperframes/studio 0.7.15 → 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 (65) hide show
  1. package/dist/assets/index-C4cUbqql.js +375 -0
  2. package/dist/assets/{index-CTXExGxO.js → index-D2SMcOdY.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/{index-Db9KG66z.js → index-sCOjSz40.js} +1 -1
  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
@@ -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
  )}
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useRef, useState } from "react";
1
+ import { memo } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
3
 
4
4
  interface KeyframeEntry {
@@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps {
28
28
  selectedKeyframes: Set<string>;
29
29
  onClickKeyframe?: (percentage: number) => void;
30
30
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
31
- onDragKeyframe?: (percentage: number, newPercentage: number) => void;
32
31
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
33
- /** Snap a clip-relative percentage to the nearest beat (returns it unchanged
34
- * when no beat is within range). Drives live beat-snapping while dragging. */
35
- snapPct?: (percentage: number) => number;
36
- /** Select this element when a keyframe drag begins, so its GSAP session is
37
- * loaded by the time the move commits (diamonds render on unselected clips
38
- * too, and a drag suppresses the selecting click). */
39
- onPickForDrag?: () => void;
40
32
  }
41
33
 
42
34
  const DIAMOND_RATIO = 0.8;
@@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
59
51
  selectedKeyframes,
60
52
  onClickKeyframe,
61
53
  onShiftClickKeyframe,
62
- onDragKeyframe,
63
54
  onContextMenuKeyframe,
64
- snapPct,
65
- onPickForDrag,
66
55
  }: TimelineClipDiamondsProps) {
67
- // Live drag: which keyframe (by original %) is being dragged and its current
68
- // (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
69
- const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
70
- const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
71
- // Commit through the latest callback, not the one captured at pointer-down:
72
- // selecting the element on drag-start loads its GSAP session asynchronously,
73
- // and the commit must use the closure that sees the loaded session.
74
- const onDragKeyframeRef = useRef(onDragKeyframe);
75
- onDragKeyframeRef.current = onDragKeyframe;
76
- // Optimistic hold: after a commit, keep the diamond at the dropped position
77
- // until the cache reflects the change (the file round-trip rewrites
78
- // keyframesData), so it doesn't flash back to the old spot in between.
79
- const pendingRef = useRef(false);
80
- const pendingHeldPctRef = useRef<number | null>(null);
81
- const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
82
- // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
83
- // (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
84
- const dragCleanupRef = useRef<(() => void) | null>(null);
85
-
86
- useEffect(() => {
87
- if (!pendingRef.current) return;
88
- // Only release the optimistic hold once the cache actually reflects the
89
- // committed position (a keyframe near the held %). An unrelated cache
90
- // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
91
- // percentages — releasing then would flash the diamond back to the old spot.
92
- const held = pendingHeldPctRef.current;
93
- if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
94
- return;
95
- }
96
- pendingRef.current = false;
97
- pendingHeldPctRef.current = null;
98
- if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
99
- setDrag(null);
100
- }, [keyframesData]);
101
-
102
- useEffect(
103
- () => () => {
104
- clearTimeout(pendingTimerRef.current ?? undefined);
105
- dragCleanupRef.current?.();
106
- },
107
- [],
108
- );
109
-
110
56
  if (clipWidthPx < 20) return null;
111
57
 
112
58
  // When the beat strip occupies the top band, shrink the diamonds and center
@@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
129
75
  }
130
76
  };
131
77
 
132
- const handlePointerDown = (e: React.PointerEvent, pct: number) => {
133
- if (e.button !== 0) return;
134
- e.stopPropagation();
135
- // Ignore a new drag while a prior drop is still settling: `pct` comes from
136
- // props (the pre-drop position) but the diamond is held at its dropped spot
137
- // via effPct(), so a re-grab would track from a stale origin and commit
138
- // against the wrong tween. The hold clears on the cache round-trip (≤2s).
139
- if (pendingRef.current) return;
140
- // Select the element up front so its GSAP session loads during the drag and
141
- // the commit (which resolves the animation from the selection) isn't a no-op.
142
- onPickForDrag?.();
143
- const startX = e.clientX;
144
- dragRef.current = { origPct: pct, pct, moved: false };
145
-
146
- const handleMove = (me: PointerEvent) => {
147
- const d = dragRef.current;
148
- if (!d) return;
149
- const dx = me.clientX - startX;
150
- // 4px dead zone so a click doesn't register as a drag.
151
- if (!d.moved && Math.abs(dx) <= 4) return;
152
- d.moved = true;
153
- const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
154
- const snapped = snapPct ? snapPct(rawPct) : rawPct;
155
- d.pct = snapped;
156
- setDrag({ origPct: pct, pct: snapped });
157
- };
158
-
159
- const handleUp = () => {
160
- document.removeEventListener("pointermove", handleMove);
161
- document.removeEventListener("pointerup", handleUp);
162
- dragCleanupRef.current = null;
163
- const d = dragRef.current;
164
- dragRef.current = null;
165
- const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
166
- if (willCommit && d) {
167
- // Hold the dropped position optimistically; the effect clears it once the
168
- // cache round-trip lands (fallback timeout in case it never does).
169
- pendingRef.current = true;
170
- pendingHeldPctRef.current = d.pct;
171
- setDrag({ origPct: d.origPct, pct: d.pct });
172
- if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
173
- pendingTimerRef.current = setTimeout(() => {
174
- pendingRef.current = false;
175
- pendingHeldPctRef.current = null;
176
- setDrag(null);
177
- }, 2000);
178
- onDragKeyframeRef.current?.(d.origPct, d.pct);
179
- } else {
180
- setDrag(null);
181
- }
182
- };
183
-
184
- dragCleanupRef.current = () => {
185
- document.removeEventListener("pointermove", handleMove);
186
- document.removeEventListener("pointerup", handleUp);
187
- };
188
-
189
- document.addEventListener("pointermove", handleMove);
190
- document.addEventListener("pointerup", handleUp);
191
- };
192
-
193
- const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
194
-
195
78
  return (
196
79
  <div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
197
80
  {sorted.map((kf, i) => {
198
81
  if (i === 0) return null;
199
82
  const prev = sorted[i - 1]!;
200
- const x1 = Math.max(
201
- 0,
202
- Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
203
- );
204
- const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
83
+ const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
84
+ const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
205
85
  if (x2 - x1 < 1) return null;
206
86
  return (
207
87
  <div
@@ -227,7 +107,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
227
107
  // is the clip's left edge (the diamond's left half overflows, which the
228
108
  // overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
229
109
  // boundary keyframes (0% / 100%) would render off-center.
230
- const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
110
+ const leftPx = (kf.percentage / 100) * clipWidthPx - half;
231
111
  const kfKey = `${elementId}:${kf.percentage}`;
232
112
  const isKfSelected = selectedKeyframes.has(kfKey);
233
113
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -252,7 +132,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
252
132
  padding: 0,
253
133
  }}
254
134
  onClick={(e) => handleClick(e, kf.percentage)}
255
- onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
256
135
  onContextMenu={(e) => {
257
136
  e.preventDefault();
258
137
  e.stopPropagation();
@@ -39,6 +39,5 @@ export interface TimelineEditCallbacks {
39
39
  onDeleteKeyframe?: (elementId: string, percentage: number) => void;
40
40
  onDeleteAllKeyframes?: (elementId: string) => void;
41
41
  onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
42
- onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
43
42
  onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
44
43
  }
@@ -122,4 +122,47 @@ describe("buildExpandedElements", () => {
122
122
  expect(child.key).toBe("index.html#eyebrow");
123
123
  expect(child.key).toBe(expectedStoreKey);
124
124
  });
125
+
126
+ // Sub-comp internals (group + pills) have no data-start, so they're not in the
127
+ // manifest. They arrive as DOM children and must still expand under their host.
128
+ it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
129
+ const elements = [
130
+ el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
131
+ ];
132
+ const manifest = [
133
+ clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
134
+ ];
135
+ // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
136
+ // are in the manifest; they're DOM children with parent links.
137
+ const parentMap = new Map([
138
+ ["group-1", "scene-host"],
139
+ ["pill-1", "group-1"],
140
+ ["pill-2", "group-1"],
141
+ ["pill-3", "group-1"],
142
+ ]);
143
+ const domClipChildren = [
144
+ { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" },
145
+ { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" },
146
+ { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" },
147
+ { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" },
148
+ ];
149
+
150
+ // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
151
+ const out = buildExpandedElements(
152
+ elements,
153
+ manifest,
154
+ parentMap,
155
+ "scene-host",
156
+ "group-1",
157
+ domClipChildren,
158
+ );
159
+ const pills = out.filter((e) => e.domId?.startsWith("pill-"));
160
+ expect(pills).toHaveLength(3);
161
+ // Children span the host's bounds and rebase onto the host's file.
162
+ expect(pills[0]!.start).toBe(5);
163
+ expect(pills[0]!.duration).toBe(6);
164
+ expect(pills[0]!.sourceFile).toBe("scene.html");
165
+ // The host row is replaced by its children.
166
+ expect(out.some((e) => e.domId === "scene-host")).toBe(false);
167
+ });
125
168
  });