@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
|
@@ -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 {
|
|
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
|
|
356
|
-
|
|
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
|
-
|
|
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
|
|
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?: {
|
|
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
|
-
|
|
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
|
)}
|