@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.
- package/dist/assets/index-C4cUbqql.js +375 -0
- package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
- package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
- package/dist/chunk-SBGXX7WY.js.map +1 -0
- package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
- package/dist/index.d.ts +23 -6
- package/dist/index.html +2 -2
- package/dist/index.js +3158 -2809
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +0 -41
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/TimelineToolbar.tsx +4 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +2 -2
- package/src/components/editor/GsapAnimationSection.tsx +3 -3
- package/src/components/editor/InspectorHeaderActions.tsx +62 -0
- package/src/components/editor/LayersPanel.tsx +35 -2
- package/src/components/editor/MotionPathOverlay.tsx +24 -8
- package/src/components/editor/PropertyPanel.tsx +48 -60
- package/src/components/editor/Transform3DCube.tsx +84 -85
- package/src/components/editor/domEditOverlayGeometry.ts +28 -0
- package/src/components/editor/domEditingDom.ts +36 -2
- package/src/components/editor/domEditingGroups.ts +41 -0
- package/src/components/editor/domEditingLayers.test.ts +97 -1
- package/src/components/editor/domEditingLayers.ts +13 -29
- package/src/components/editor/domEditingTypes.ts +4 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
- package/src/components/editor/gsapAnimationHelpers.ts +1 -1
- package/src/components/editor/manualEditsTypes.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +23 -2
- package/src/components/editor/motionPathCommit.test.ts +1 -1
- package/src/components/editor/motionPathSelection.ts +1 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
- package/src/components/editor/propertyPanelHelpers.ts +6 -4
- package/src/components/editor/useDomEditOverlayRects.ts +8 -3
- package/src/components/editor/useMotionPathData.ts +47 -1
- package/src/contexts/DomEditContext.tsx +16 -0
- package/src/contexts/TimelineEditContext.tsx +0 -1
- package/src/hooks/gsapRuntimeBridge.ts +9 -2
- package/src/hooks/gsapTweenSynth.ts +64 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
- package/src/hooks/useAppHotkeys.ts +34 -3
- package/src/hooks/useDomEditSession.ts +48 -0
- package/src/hooks/useDomSelection.ts +73 -9
- package/src/hooks/useEnableKeyframes.test.ts +35 -0
- package/src/hooks/useEnableKeyframes.ts +29 -1
- package/src/hooks/useGroupCommits.ts +188 -0
- package/src/hooks/useGsapSelectionHandlers.ts +4 -0
- package/src/hooks/useGsapTweenCache.ts +56 -72
- package/src/hooks/usePreviewInteraction.ts +60 -2
- package/src/player/components/ShortcutsPanel.tsx +2 -0
- package/src/player/components/Timeline.tsx +0 -15
- package/src/player/components/TimelineCanvas.tsx +0 -13
- package/src/player/components/TimelineClipDiamonds.tsx +4 -125
- package/src/player/components/timelineCallbacks.ts +0 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
- package/src/player/store/playerStore.ts +20 -0
- package/src/utils/sdkCutoverParity.test.ts +1 -1
- package/src/utils/studioPreviewHelpers.ts +37 -0
- package/dist/assets/index-BXG-pbr_.js +0 -375
- package/dist/assets/index-svYFaNuq.css +0 -1
- package/dist/chunk-KZXYQYIU.js.map +0 -1
- package/src/components/editor/keyframeMove.test.ts +0 -101
- package/src/components/editor/keyframeMove.ts +0 -151
- /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo
|
|
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
|
-
|
|
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 = (
|
|
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
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
|
|
3
3
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
4
|
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
5
|
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
@@ -111,6 +111,32 @@ function buildChildElements(
|
|
|
111
111
|
return result;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing
|
|
115
|
+
// of their own — they're "always on" within their sub-comp host, so synthesize
|
|
116
|
+
// clips spanning the host's full bounds. The host element supplies start/duration
|
|
117
|
+
// and the composition file edits write to.
|
|
118
|
+
function domSiblingClips(
|
|
119
|
+
domClipChildren: DomClipChild[],
|
|
120
|
+
siblingParentId: string,
|
|
121
|
+
host: TimelineElement,
|
|
122
|
+
): ClipManifestClip[] {
|
|
123
|
+
return domClipChildren
|
|
124
|
+
.filter((c) => c.parentId === siblingParentId)
|
|
125
|
+
.map((c) => ({
|
|
126
|
+
id: c.id,
|
|
127
|
+
label: c.label,
|
|
128
|
+
start: host.start,
|
|
129
|
+
duration: host.duration,
|
|
130
|
+
track: host.track,
|
|
131
|
+
kind: "element" as const,
|
|
132
|
+
tagName: null,
|
|
133
|
+
compositionId: null,
|
|
134
|
+
parentCompositionId: host.id ?? null,
|
|
135
|
+
compositionSrc: host.compositionSrc ?? null,
|
|
136
|
+
assetUrl: null,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
114
140
|
// Exported for tests.
|
|
115
141
|
export function buildExpandedElements(
|
|
116
142
|
elements: TimelineElement[],
|
|
@@ -118,11 +144,20 @@ export function buildExpandedElements(
|
|
|
118
144
|
parentMap: Map<string, string>,
|
|
119
145
|
topLevelId: string,
|
|
120
146
|
siblingParentId: string,
|
|
147
|
+
domClipChildren: DomClipChild[] = [],
|
|
121
148
|
): TimelineElement[] {
|
|
122
149
|
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
|
123
150
|
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
|
124
151
|
|
|
125
|
-
|
|
152
|
+
// Prefer real manifest children; fall back to DOM-only sub-comp children
|
|
153
|
+
// (groups/pills) that have no data-start and thus never enter the manifest.
|
|
154
|
+
const siblings = (() => {
|
|
155
|
+
const fromManifest = manifest.filter(
|
|
156
|
+
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
|
|
157
|
+
);
|
|
158
|
+
if (fromManifest.length > 0) return fromManifest;
|
|
159
|
+
return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
|
|
160
|
+
})();
|
|
126
161
|
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
|
127
162
|
|
|
128
163
|
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
@@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
|
|
154
189
|
const elements = usePlayerStore((s) => s.elements);
|
|
155
190
|
const clipManifest = usePlayerStore((s) => s.clipManifest);
|
|
156
191
|
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
|
192
|
+
const domClipChildren = usePlayerStore((s) => s.domClipChildren);
|
|
157
193
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
158
194
|
|
|
159
195
|
return useMemo(() => {
|
|
@@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
|
|
166
202
|
|
|
167
203
|
const immediateParent = clipParentMap.get(rawId)!;
|
|
168
204
|
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
|
169
|
-
return buildExpandedElements(
|
|
170
|
-
|
|
205
|
+
return buildExpandedElements(
|
|
206
|
+
elements,
|
|
207
|
+
clipManifest,
|
|
208
|
+
clipParentMap,
|
|
209
|
+
topLevel,
|
|
210
|
+
immediateParent,
|
|
211
|
+
domClipChildren,
|
|
212
|
+
);
|
|
213
|
+
}, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]);
|
|
171
214
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { useCallback } from "react";
|
|
12
12
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
13
|
-
import type { TimelineElement } from "../store/playerStore";
|
|
13
|
+
import type { TimelineElement, DomClipChild } from "../store/playerStore";
|
|
14
14
|
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
|
|
15
15
|
import {
|
|
16
16
|
parseTimelineFromDOM,
|
|
@@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({
|
|
|
85
85
|
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
|
|
86
86
|
| null;
|
|
87
87
|
const clipTree = iframeWin?.__clipTree;
|
|
88
|
+
const parentMap = new Map<string, string>();
|
|
88
89
|
if (clipTree) {
|
|
89
|
-
const parentMap = new Map<string, string>();
|
|
90
90
|
const walk = (nodes: typeof clipTree.roots) => {
|
|
91
91
|
for (const node of nodes) {
|
|
92
92
|
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
|
|
@@ -94,11 +94,50 @@ export function useTimelineSyncCallbacks({
|
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
96
|
walk(clipTree.roots);
|
|
97
|
-
usePlayerStore.getState().setClipParentMap(parentMap);
|
|
98
97
|
}
|
|
98
|
+
|
|
99
|
+
// Descend into each sub-composition host: its internal elements (group
|
|
100
|
+
// wrappers + their children) carry no `data-start`, so the clip
|
|
101
|
+
// tree/manifest never enumerate them. Surface them studio-side as DOM
|
|
102
|
+
// children + parent links so the timeline can expand a sub-comp/group
|
|
103
|
+
// row to show them. Manifest stays lean (timed clips only).
|
|
104
|
+
const domClipChildren: DomClipChild[] = [];
|
|
105
|
+
if (iframeDoc) {
|
|
106
|
+
for (const clip of data.clips) {
|
|
107
|
+
if (clip.kind !== "composition" || !clip.id) continue;
|
|
108
|
+
const hostEl = iframeDoc.getElementById(clip.id);
|
|
109
|
+
if (!hostEl) continue;
|
|
110
|
+
const hostId = clip.id;
|
|
111
|
+
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
|
|
112
|
+
// Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they
|
|
113
|
+
// expand into timeline rows. Descends through id-less structural wrappers
|
|
114
|
+
// (the inlined sub-comp body), and one level into groups for drill-in.
|
|
115
|
+
const collect = (parentEl: Element, parentId: string) => {
|
|
116
|
+
for (const child of Array.from(parentEl.children)) {
|
|
117
|
+
if (!child.id) {
|
|
118
|
+
collect(child, parentId); // unwrap id-less structural containers
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const isGroup = child.hasAttribute("data-hf-group");
|
|
122
|
+
domClipChildren.push({
|
|
123
|
+
id: child.id,
|
|
124
|
+
parentId,
|
|
125
|
+
hostId,
|
|
126
|
+
label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
|
|
127
|
+
});
|
|
128
|
+
parentMap.set(child.id, parentId);
|
|
129
|
+
if (isGroup) collect(child, child.id);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
collect(innerRoot, hostId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
usePlayerStore.getState().setClipParentMap(parentMap);
|
|
136
|
+
usePlayerStore.getState().setDomClipChildren(domClipChildren);
|
|
99
137
|
} catch {
|
|
100
|
-
// cross-origin or __clipTree not available —
|
|
138
|
+
// cross-origin or __clipTree not available — maps stay empty
|
|
101
139
|
}
|
|
140
|
+
|
|
102
141
|
const usedHostEls = new Set<Element>();
|
|
103
142
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
104
143
|
const hostEl = iframeDoc
|
|
@@ -165,6 +165,23 @@ interface PlayerState {
|
|
|
165
165
|
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
|
166
166
|
clipParentMap: Map<string, string>;
|
|
167
167
|
setClipParentMap: (map: Map<string, string>) => void;
|
|
168
|
+
/**
|
|
169
|
+
* Sub-composition DOM descendants (groups + their children) that have no
|
|
170
|
+
* `data-start`, so they're absent from the clip manifest/tree. Collected
|
|
171
|
+
* studio-side from the live preview so the timeline can expand a sub-comp row
|
|
172
|
+
* to show its DOM-only children. Keeps the manifest lean (timed clips only).
|
|
173
|
+
*/
|
|
174
|
+
domClipChildren: DomClipChild[];
|
|
175
|
+
setDomClipChildren: (children: DomClipChild[]) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
|
|
179
|
+
export interface DomClipChild {
|
|
180
|
+
id: string;
|
|
181
|
+
parentId: string;
|
|
182
|
+
/** The manifest sub-comp host clip id this descendant ultimately lives under. */
|
|
183
|
+
hostId: string;
|
|
184
|
+
label: string;
|
|
168
185
|
}
|
|
169
186
|
|
|
170
187
|
interface BeatHistoryEntry {
|
|
@@ -296,6 +313,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
296
313
|
setClipManifest: (clips) => set({ clipManifest: clips }),
|
|
297
314
|
clipParentMap: new Map(),
|
|
298
315
|
setClipParentMap: (map) => set({ clipParentMap: map }),
|
|
316
|
+
domClipChildren: [],
|
|
317
|
+
setDomClipChildren: (children) => set({ domClipChildren: children }),
|
|
299
318
|
|
|
300
319
|
setIsPlaying: (playing) => {
|
|
301
320
|
if (get().isPlaying === playing) return;
|
|
@@ -380,6 +399,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
380
399
|
beatPersist: null,
|
|
381
400
|
clipManifest: null,
|
|
382
401
|
clipParentMap: new Map(),
|
|
402
|
+
domClipChildren: [],
|
|
383
403
|
}),
|
|
384
404
|
}));
|
|
385
405
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { openComposition } from "@hyperframes/sdk";
|
|
3
|
-
import { patchElementInHtml } from "../../../
|
|
3
|
+
import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js";
|
|
4
4
|
import type { PatchOperation } from "./sourcePatcher";
|
|
5
5
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
6
6
|
|
|
@@ -81,6 +81,37 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Animated group members can move outside their wrapper's static layout box, so
|
|
85
|
+
// the empty space inside a group's *visual* bounds (the member-union the overlay
|
|
86
|
+
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
|
|
87
|
+
// point falls within a group's live member-union rect, return that wrapper.
|
|
88
|
+
// Innermost (smallest-area) group wins for nested groups.
|
|
89
|
+
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
|
|
90
|
+
let best: HTMLElement | null = null;
|
|
91
|
+
let bestArea = Infinity;
|
|
92
|
+
for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
|
|
93
|
+
let left = Infinity;
|
|
94
|
+
let top = Infinity;
|
|
95
|
+
let right = -Infinity;
|
|
96
|
+
let bottom = -Infinity;
|
|
97
|
+
for (const member of Array.from(group.children)) {
|
|
98
|
+
const r = member.getBoundingClientRect();
|
|
99
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
100
|
+
left = Math.min(left, r.left);
|
|
101
|
+
top = Math.min(top, r.top);
|
|
102
|
+
right = Math.max(right, r.right);
|
|
103
|
+
bottom = Math.max(bottom, r.bottom);
|
|
104
|
+
}
|
|
105
|
+
if (right < left || x < left || x > right || y < top || y > bottom) continue;
|
|
106
|
+
const area = (right - left) * (bottom - top);
|
|
107
|
+
if (area < bestArea) {
|
|
108
|
+
bestArea = area;
|
|
109
|
+
best = group;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return best;
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
// fallow-ignore-next-line complexity
|
|
85
116
|
export function getPreviewTargetFromPointer(
|
|
86
117
|
iframe: HTMLIFrameElement,
|
|
@@ -113,6 +144,12 @@ export function getPreviewTargetFromPointer(
|
|
|
113
144
|
if (visualTarget) return visualTarget;
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
|
148
|
+
// back to the group whose member-union contains the point, so the whole group
|
|
149
|
+
// area is hoverable/selectable, not just where a member currently sits.
|
|
150
|
+
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
|
151
|
+
if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
|
|
152
|
+
|
|
116
153
|
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
117
154
|
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
|
118
155
|
if (!isElementComputedVisible(fallback)) return null;
|