@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.
- package/dist/assets/index-C4cUbqql.js +375 -0
- package/dist/assets/{index-CTXExGxO.js → index-D2SMcOdY.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/{index-Db9KG66z.js → index-sCOjSz40.js} +1 -1
- package/dist/{chunk-UUEDZ4UJ.js → chunk-SBGXX7WY.js} +52 -22
- package/dist/chunk-SBGXX7WY.js.map +1 -0
- package/dist/{domEditingLayers-NWCBED4R.js → domEditingLayers-VZMLL4AP.js} +2 -4
- package/dist/index.d.ts +18 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3157 -2808
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- 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 +3 -0
- package/src/components/editor/manualOffsetDrag.ts +23 -2
- package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- 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/studioPreviewHelpers.ts +37 -0
- package/dist/assets/index-B7VWFKy1.js +0 -375
- package/dist/assets/index-svYFaNuq.css +0 -1
- package/dist/chunk-UUEDZ4UJ.js.map +0 -1
- package/src/components/editor/keyframeMove.test.ts +0 -101
- package/src/components/editor/keyframeMove.ts +0 -151
- /package/dist/{domEditingLayers-NWCBED4R.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
|
@@ -6,6 +6,20 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
|
|
|
6
6
|
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
7
7
|
import { Transform3DCube, type CubePose } from "./Transform3DCube";
|
|
8
8
|
|
|
9
|
+
// translateZ only foreshortens under a perspective lens. Rather than hardcode one
|
|
10
|
+
// (an arbitrary px value reads wrong at different canvas sizes), derive it from the
|
|
11
|
+
// element's composition: perspective = composition height puts the virtual camera
|
|
12
|
+
// one comp-height back, a natural ~53° vertical FOV that looks the same whether the
|
|
13
|
+
// canvas is 720p or 4K. Falls back to the element's own height only if the comp size
|
|
14
|
+
// can't be read (detached/unmeasured), never to a fixed magic number.
|
|
15
|
+
function naturalDepthPerspective(el: HTMLElement | null | undefined): number {
|
|
16
|
+
if (!el) return 0;
|
|
17
|
+
const root = el.closest("[data-hf-inner-root],[data-composition-id]") as HTMLElement | null;
|
|
18
|
+
const compHeight = root?.offsetHeight || el.ownerDocument?.documentElement?.clientHeight || 0;
|
|
19
|
+
if (compHeight > 0) return Math.round(compHeight);
|
|
20
|
+
return Math.round((el.offsetHeight || 0) * 4) || 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
type KeyframeEntry = Array<{
|
|
10
24
|
percentage: number;
|
|
11
25
|
properties: Record<string, number | string>;
|
|
@@ -38,17 +52,10 @@ interface PropertyPanel3dTransformProps {
|
|
|
38
52
|
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
type CommitAnimatedProperty = (
|
|
42
|
-
element: DomEditSelection,
|
|
43
|
-
property: string,
|
|
44
|
-
value: number,
|
|
45
|
-
) => Promise<void>;
|
|
46
|
-
|
|
47
55
|
/** The draggable cube + its commit/recenter/live-preview wiring. */
|
|
48
56
|
function Cube3dControl({
|
|
49
57
|
element,
|
|
50
58
|
gsapRuntimeValues,
|
|
51
|
-
onCommitAnimatedProperty,
|
|
52
59
|
onCommitAnimatedProperties,
|
|
53
60
|
onLivePreviewProps,
|
|
54
61
|
onKeyframe,
|
|
@@ -56,8 +63,7 @@ function Cube3dControl({
|
|
|
56
63
|
}: {
|
|
57
64
|
element: DomEditSelection;
|
|
58
65
|
gsapRuntimeValues: Record<string, number>;
|
|
59
|
-
|
|
60
|
-
onCommitAnimatedProperties?: (
|
|
66
|
+
onCommitAnimatedProperties: (
|
|
61
67
|
element: DomEditSelection,
|
|
62
68
|
props: Record<string, number | string>,
|
|
63
69
|
) => Promise<void>;
|
|
@@ -70,6 +76,15 @@ function Cube3dControl({
|
|
|
70
76
|
rotationY: gsapRuntimeValues.rotationY ?? 0,
|
|
71
77
|
rotationZ: gsapRuntimeValues.rotationZ ?? 0,
|
|
72
78
|
};
|
|
79
|
+
// Comp-derived lens (see naturalDepthPerspective) applied the first time depth is
|
|
80
|
+
// set, so the scene's foreshortening scales with the canvas instead of a magic 800.
|
|
81
|
+
const depthPerspective = naturalDepthPerspective(element.element);
|
|
82
|
+
// A gentle, fixed "depth pose" tilt (degrees) dropped on a flat element the first
|
|
83
|
+
// time it gets depth, so translateZ reads as 3D foreshortening instead of a plain
|
|
84
|
+
// resize — small enough to look like a premium card, not a flip.
|
|
85
|
+
const DEPTH_POSE_X = 10;
|
|
86
|
+
const DEPTH_POSE_Y = -15;
|
|
87
|
+
const isFlat = Math.round(pose.rotationX) === 0 && Math.round(pose.rotationY) === 0;
|
|
73
88
|
// Commit only the rotation axes the drag actually changed (each rounded to a
|
|
74
89
|
// whole degree). Reuses the keyframe-aware animated-property commit, so a drag
|
|
75
90
|
// at the playhead writes/updates a keyframe just like the numeric fields.
|
|
@@ -82,13 +97,8 @@ function Cube3dControl({
|
|
|
82
97
|
const axes = Object.keys(changedProps);
|
|
83
98
|
if (axes.length === 0) return;
|
|
84
99
|
// ONE keyframe for the whole pose change — avoids per-axis commits racing into
|
|
85
|
-
// adjacent duplicate keyframes.
|
|
86
|
-
|
|
87
|
-
void onCommitAnimatedProperties(element, changedProps);
|
|
88
|
-
} else {
|
|
89
|
-
for (const [axis, v] of Object.entries(changedProps))
|
|
90
|
-
onCommitAnimatedProperty(element, axis, v);
|
|
91
|
-
}
|
|
100
|
+
// adjacent duplicate keyframes.
|
|
101
|
+
void onCommitAnimatedProperties(element, changedProps);
|
|
92
102
|
};
|
|
93
103
|
const recenter = () => {
|
|
94
104
|
// ONE commit for the whole reset — six per-axis commits meant six soft-reloads
|
|
@@ -101,15 +111,10 @@ function Cube3dControl({
|
|
|
101
111
|
scale: 1,
|
|
102
112
|
transformPerspective: 0,
|
|
103
113
|
};
|
|
104
|
-
|
|
105
|
-
void onCommitAnimatedProperties(element, identity);
|
|
106
|
-
} else {
|
|
107
|
-
for (const [prop, v] of Object.entries(identity))
|
|
108
|
-
void onCommitAnimatedProperty(element, prop, v);
|
|
109
|
-
}
|
|
114
|
+
void onCommitAnimatedProperties(element, identity);
|
|
110
115
|
};
|
|
111
116
|
// Immediate element feedback while dragging — set the live transform without a
|
|
112
|
-
// source write; the release commits via
|
|
117
|
+
// source write; the release commits via commitPose.
|
|
113
118
|
const livePreview = (next: CubePose) =>
|
|
114
119
|
onLivePreviewProps?.(element, {
|
|
115
120
|
rotationX: next.rotationX,
|
|
@@ -123,18 +128,54 @@ function Cube3dControl({
|
|
|
123
128
|
<Transform3DCube
|
|
124
129
|
pose={pose}
|
|
125
130
|
perspective={gsapRuntimeValues.transformPerspective ?? 0}
|
|
131
|
+
defaultPerspective={depthPerspective}
|
|
132
|
+
z={gsapRuntimeValues.z ?? 0}
|
|
126
133
|
onPoseDraft={livePreview}
|
|
127
134
|
onPoseCommit={commitPose}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
onDepthDraft={(z) => {
|
|
136
|
+
// Preview WITH a lens so depth is visible while scrolling — the same
|
|
137
|
+
// default the commit applies, so the element doesn't snap on release.
|
|
138
|
+
const preview: Record<string, number> = gsapRuntimeValues.transformPerspective
|
|
139
|
+
? { z }
|
|
140
|
+
: { z, transformPerspective: depthPerspective };
|
|
141
|
+
// Depth-pose preview: a flat element only scales under Z, so mirror the
|
|
142
|
+
// commit and preview the gentle tilt that makes the depth read as 3D.
|
|
143
|
+
if (isFlat) {
|
|
144
|
+
preview.rotationX = DEPTH_POSE_X;
|
|
145
|
+
preview.rotationY = DEPTH_POSE_Y;
|
|
146
|
+
}
|
|
147
|
+
onLivePreviewProps?.(element, preview);
|
|
148
|
+
}}
|
|
149
|
+
onDepthCommit={(z) => {
|
|
150
|
+
// Best-UX depth: scroll moves Z, and a 3D transform always has a lens —
|
|
151
|
+
// like an After Effects camera. translateZ is invisible without a
|
|
152
|
+
// perspective, so the FIRST time depth is added (Perspective still 0) we
|
|
153
|
+
// set a sensible comp-derived lens ONCE. Every later scroll touches Z
|
|
154
|
+
// only, and Perspective stays an independent, editable field. The cube's
|
|
155
|
+
// scroll is clamped in front of the lens, so Z can't run away past it.
|
|
156
|
+
const props: Record<string, number> = { z };
|
|
157
|
+
if (!gsapRuntimeValues.transformPerspective && depthPerspective > 0) {
|
|
158
|
+
props.transformPerspective = depthPerspective;
|
|
159
|
+
}
|
|
160
|
+
// Depth-pose: a flat element (no tilt) only scales under Z — it can't read
|
|
161
|
+
// as depth. So the first time depth lands on a flat element, also drop a
|
|
162
|
+
// gentle fixed tilt; the foreshortening makes depth read as 3D IN PLACE
|
|
163
|
+
// (no screen travel, per-element lens unchanged). Once the element has any
|
|
164
|
+
// tilt, depth scrolls touch Z only. Reset tilt to 0 to go flat again.
|
|
165
|
+
if (isFlat) {
|
|
166
|
+
props.rotationX = DEPTH_POSE_X;
|
|
167
|
+
props.rotationY = DEPTH_POSE_Y;
|
|
168
|
+
}
|
|
169
|
+
// One commit for all props so the writes can't race read-modify-write on
|
|
170
|
+
// the same script (which dropped a prop and reverted after a seek).
|
|
171
|
+
void onCommitAnimatedProperties(element, props);
|
|
172
|
+
}}
|
|
132
173
|
onRecenter={recenter}
|
|
133
174
|
onKeyframe={onKeyframe}
|
|
134
175
|
keyframed={keyframed}
|
|
135
176
|
/>
|
|
136
177
|
<p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
|
|
137
|
-
Drag to tilt · Shift-drag to roll
|
|
178
|
+
Drag to tilt · Shift-drag to roll · Scroll for depth
|
|
138
179
|
</p>
|
|
139
180
|
</div>
|
|
140
181
|
</div>
|
|
@@ -286,11 +327,10 @@ export function PropertyPanel3dTransform({
|
|
|
286
327
|
</button>
|
|
287
328
|
{collapsed ? null : (
|
|
288
329
|
<>
|
|
289
|
-
{
|
|
330
|
+
{onCommitAnimatedProperties && (
|
|
290
331
|
<Cube3dControl
|
|
291
332
|
element={element}
|
|
292
333
|
gsapRuntimeValues={gsapRuntimeValues}
|
|
293
|
-
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
|
294
334
|
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
|
295
335
|
onLivePreviewProps={onLivePreviewProps}
|
|
296
336
|
keyframed={(gsapKeyframes ?? []).some(
|
|
@@ -13,6 +13,8 @@ export interface PropertyPanelProps {
|
|
|
13
13
|
multiSelectCount?: number;
|
|
14
14
|
copiedAgentPrompt: boolean;
|
|
15
15
|
onClearSelection: () => void;
|
|
16
|
+
/** Dissolve the selected data-hf-group wrapper (shown only for group selections). */
|
|
17
|
+
onUngroup?: () => void;
|
|
16
18
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
17
19
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
18
20
|
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ResolvedElementRef,
|
|
12
12
|
groupOverlayItemsEqual,
|
|
13
13
|
isElementVisibleForOverlay,
|
|
14
|
+
groupAwareOverlayRect,
|
|
14
15
|
rectsEqual,
|
|
15
16
|
resolveElementForOverlay,
|
|
16
17
|
selectionCacheKey,
|
|
@@ -155,7 +156,7 @@ export function useDomEditOverlayRects({
|
|
|
155
156
|
// backgroundless full-bleed scene above a subcomposition), which would wrongly
|
|
156
157
|
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
|
|
157
158
|
if (el && isElementVisibleForOverlay(el)) {
|
|
158
|
-
const nextRect =
|
|
159
|
+
const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
|
|
159
160
|
setOverlayRect(nextRect);
|
|
160
161
|
const descendants = el.querySelectorAll("*");
|
|
161
162
|
if (descendants.length > 0 && descendants.length <= 60) {
|
|
@@ -196,9 +197,13 @@ export function useDomEditOverlayRects({
|
|
|
196
197
|
const liveGroupKeys = new Set<string>();
|
|
197
198
|
for (const groupSelection of group) {
|
|
198
199
|
const key = selectionCacheKey(groupSelection);
|
|
200
|
+
// Members of the same group collapse to one selection under select-as-unit,
|
|
201
|
+
// so a multi-select can hold the same group twice — dedupe by key to avoid
|
|
202
|
+
// duplicate React keys (and a doubled overlay box).
|
|
203
|
+
if (liveGroupKeys.has(key)) continue;
|
|
199
204
|
liveGroupKeys.add(key);
|
|
200
205
|
const el = resolveGroupElement(doc, groupSelection);
|
|
201
|
-
const rect = el ?
|
|
206
|
+
const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
|
|
202
207
|
if (el && rect)
|
|
203
208
|
nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
|
|
204
209
|
}
|
|
@@ -235,7 +240,7 @@ export function useDomEditOverlayRects({
|
|
|
235
240
|
return;
|
|
236
241
|
}
|
|
237
242
|
|
|
238
|
-
setHoverRect(
|
|
243
|
+
setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
|
|
239
244
|
};
|
|
240
245
|
|
|
241
246
|
frame = requestAnimationFrame(update);
|
|
@@ -5,6 +5,38 @@ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGe
|
|
|
5
5
|
|
|
6
6
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
7
7
|
|
|
8
|
+
// The translate (e/f) components of an element's computed transform, in comp px.
|
|
9
|
+
// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top.
|
|
10
|
+
function transformTranslate(el: HTMLElement): { x: number; y: number } {
|
|
11
|
+
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
|
|
12
|
+
if (!t || t === "none") return { x: 0, y: 0 };
|
|
13
|
+
const m3 = t.match(/matrix3d\(([^)]+)\)/);
|
|
14
|
+
if (m3) {
|
|
15
|
+
const v = m3[1].split(",").map(Number);
|
|
16
|
+
return { x: v[12] || 0, y: v[13] || 0 };
|
|
17
|
+
}
|
|
18
|
+
const m = t.match(/matrix\(([^)]+)\)/);
|
|
19
|
+
if (m) {
|
|
20
|
+
const v = m[1].split(",").map(Number);
|
|
21
|
+
return { x: v[4] || 0, y: v[5] || 0 };
|
|
22
|
+
}
|
|
23
|
+
return { x: 0, y: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Perspective foreshortening of the element's OWN transform (matrix3d m44). A
|
|
27
|
+
// depth element (translateZ toward the viewer) renders 1/m44× larger, so its
|
|
28
|
+
// animated x/y offsets travel 1/m44× further on screen than the flat preview
|
|
29
|
+
// scale implies. Returns 1 for 2D transforms. The motion path magnifies its
|
|
30
|
+
// offset points by 1/m44 (and de-magnifies pointer→offset) so the drawn path and
|
|
31
|
+
// its draggable nodes track the projected element instead of drifting off it.
|
|
32
|
+
export function transformWDivisor(el: HTMLElement): number {
|
|
33
|
+
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
|
|
34
|
+
if (!t || !t.startsWith("matrix3d(")) return 1;
|
|
35
|
+
const v = t.slice("matrix3d(".length, -1).split(",");
|
|
36
|
+
const w = Number.parseFloat(v[15] ?? "");
|
|
37
|
+
return Number.isFinite(w) && w > 0 ? w : 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
8
40
|
export function elementHome(el: HTMLElement): { x: number; y: number } {
|
|
9
41
|
let left = 0;
|
|
10
42
|
let top = 0;
|
|
@@ -12,6 +44,14 @@ export function elementHome(el: HTMLElement): { x: number; y: number } {
|
|
|
12
44
|
while (node) {
|
|
13
45
|
left += node.offsetLeft;
|
|
14
46
|
top += node.offsetTop;
|
|
47
|
+
// Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the
|
|
48
|
+
// element actually renders, so the path must anchor on top of them. The element's
|
|
49
|
+
// OWN transform is excluded — that's the animated offset the path itself draws.
|
|
50
|
+
if (node !== el) {
|
|
51
|
+
const t = transformTranslate(node);
|
|
52
|
+
left += t.x;
|
|
53
|
+
top += t.y;
|
|
54
|
+
}
|
|
15
55
|
const parent = node.offsetParent as HTMLElement | null;
|
|
16
56
|
if (!parent || parent.hasAttribute("data-composition-id")) break;
|
|
17
57
|
node = parent;
|
|
@@ -62,6 +102,7 @@ export function useMotionPathData(
|
|
|
62
102
|
geometryResolved: boolean;
|
|
63
103
|
visibleInPreview: boolean;
|
|
64
104
|
home: { x: number; y: number } | null;
|
|
105
|
+
pScale: number;
|
|
65
106
|
} {
|
|
66
107
|
const [rect, setRect] = useState<Rect | null>(null);
|
|
67
108
|
const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
|
|
@@ -69,6 +110,9 @@ export function useMotionPathData(
|
|
|
69
110
|
const geometryResolved = resolvedForRef.current === selector;
|
|
70
111
|
const [visibleInPreview, setVisibleInPreview] = useState(true);
|
|
71
112
|
const [home, setHome] = useState<{ x: number; y: number } | null>(null);
|
|
113
|
+
// Perspective magnification (1/m44) of the selected element — applied to the
|
|
114
|
+
// path's offset points so depth (translateZ) elements' paths track on screen.
|
|
115
|
+
const [pScale, setPScale] = useState(1);
|
|
72
116
|
|
|
73
117
|
useEffect(() => {
|
|
74
118
|
if (!selector) {
|
|
@@ -105,6 +149,8 @@ export function useMotionPathData(
|
|
|
105
149
|
setHome((prev) =>
|
|
106
150
|
prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
|
|
107
151
|
);
|
|
152
|
+
const ps = 1 / transformWDivisor(live);
|
|
153
|
+
setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
|
|
108
154
|
}
|
|
109
155
|
}
|
|
110
156
|
raf = requestAnimationFrame(tick);
|
|
@@ -132,5 +178,5 @@ export function useMotionPathData(
|
|
|
132
178
|
return () => window.clearInterval(id);
|
|
133
179
|
}, [selector, iframeRef]);
|
|
134
180
|
|
|
135
|
-
return { rect, geometry, geometryResolved, visibleInPreview, home };
|
|
181
|
+
return { rect, geometry, geometryResolved, visibleInPreview, home, pScale };
|
|
136
182
|
}
|
|
@@ -31,6 +31,9 @@ export interface DomEditActionsValue extends Pick<
|
|
|
31
31
|
| "handleBlockedDomMove"
|
|
32
32
|
| "handleDomManualDragStart"
|
|
33
33
|
| "handleDomEditElementDelete"
|
|
34
|
+
| "handleGroupSelection"
|
|
35
|
+
| "handleUngroupSelection"
|
|
36
|
+
| "setActiveGroupElement"
|
|
34
37
|
| "buildDomSelectionFromTarget"
|
|
35
38
|
| "buildDomSelectionForTimelineElement"
|
|
36
39
|
| "updateDomEditHoverSelection"
|
|
@@ -72,6 +75,7 @@ export interface DomEditSelectionValue extends Pick<
|
|
|
72
75
|
| "domEditSelection"
|
|
73
76
|
| "domEditGroupSelections"
|
|
74
77
|
| "domEditHoverSelection"
|
|
78
|
+
| "activeGroupElement"
|
|
75
79
|
| "domEditSelectionRef"
|
|
76
80
|
| "selectedGsapAnimations"
|
|
77
81
|
| "gsapMultipleTimelines"
|
|
@@ -138,6 +142,10 @@ export function DomEditProvider({
|
|
|
138
142
|
handleBlockedDomMove,
|
|
139
143
|
handleDomManualDragStart,
|
|
140
144
|
handleDomEditElementDelete,
|
|
145
|
+
handleGroupSelection,
|
|
146
|
+
handleUngroupSelection,
|
|
147
|
+
setActiveGroupElement,
|
|
148
|
+
activeGroupElement,
|
|
141
149
|
buildDomSelectionFromTarget,
|
|
142
150
|
buildDomSelectionForTimelineElement,
|
|
143
151
|
updateDomEditHoverSelection,
|
|
@@ -216,6 +224,9 @@ export function DomEditProvider({
|
|
|
216
224
|
handleBlockedDomMove,
|
|
217
225
|
handleDomManualDragStart,
|
|
218
226
|
handleDomEditElementDelete,
|
|
227
|
+
handleGroupSelection,
|
|
228
|
+
handleUngroupSelection,
|
|
229
|
+
setActiveGroupElement,
|
|
219
230
|
buildDomSelectionFromTarget,
|
|
220
231
|
buildDomSelectionForTimelineElement,
|
|
221
232
|
updateDomEditHoverSelection,
|
|
@@ -277,6 +288,9 @@ export function DomEditProvider({
|
|
|
277
288
|
handleBlockedDomMove,
|
|
278
289
|
handleDomManualDragStart,
|
|
279
290
|
handleDomEditElementDelete,
|
|
291
|
+
handleGroupSelection,
|
|
292
|
+
handleUngroupSelection,
|
|
293
|
+
setActiveGroupElement,
|
|
280
294
|
buildDomSelectionFromTarget,
|
|
281
295
|
buildDomSelectionForTimelineElement,
|
|
282
296
|
updateDomEditHoverSelection,
|
|
@@ -319,6 +333,7 @@ export function DomEditProvider({
|
|
|
319
333
|
domEditSelection,
|
|
320
334
|
domEditGroupSelections,
|
|
321
335
|
domEditHoverSelection,
|
|
336
|
+
activeGroupElement,
|
|
322
337
|
domEditSelectionRef,
|
|
323
338
|
selectedGsapAnimations,
|
|
324
339
|
gsapMultipleTimelines,
|
|
@@ -332,6 +347,7 @@ export function DomEditProvider({
|
|
|
332
347
|
domEditSelection,
|
|
333
348
|
domEditGroupSelections,
|
|
334
349
|
domEditHoverSelection,
|
|
350
|
+
activeGroupElement,
|
|
335
351
|
domEditSelectionRef,
|
|
336
352
|
selectedGsapAnimations,
|
|
337
353
|
gsapMultipleTimelines,
|
|
@@ -241,8 +241,15 @@ export async function tryGsapDragIntercept(
|
|
|
241
241
|
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
242
242
|
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
243
243
|
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
|
|
245
|
+
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
|
|
246
|
+
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
|
|
247
|
+
// dragging an element whose position tween is constant writes a `gsap.set` that
|
|
248
|
+
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
|
|
249
|
+
// static path is only for elements with NO keyframed position tween (truly static,
|
|
250
|
+
// or just a leftover position-hold `set`).
|
|
251
|
+
const hasKeyframedPosTween = !!posAnim?.keyframes;
|
|
252
|
+
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
246
253
|
const existingSet =
|
|
247
254
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
248
255
|
? posAnim
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GsapAnimation,
|
|
3
|
+
GsapKeyframesData,
|
|
4
|
+
GsapPercentageKeyframe,
|
|
5
|
+
} from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
7
|
+
|
|
8
|
+
export function deduplicateKeyframes(
|
|
9
|
+
keyframes: GsapPercentageKeyframe[],
|
|
10
|
+
): GsapPercentageKeyframe[] {
|
|
11
|
+
const byPct = new Map<number, GsapPercentageKeyframe>();
|
|
12
|
+
for (const kf of keyframes) {
|
|
13
|
+
const existing = byPct.get(kf.percentage);
|
|
14
|
+
if (existing) {
|
|
15
|
+
existing.properties = { ...existing.properties, ...kf.properties };
|
|
16
|
+
if (kf.ease) existing.ease = kf.ease;
|
|
17
|
+
} else {
|
|
18
|
+
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// fallow-ignore-next-line complexity
|
|
25
|
+
export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
|
26
|
+
if (anim.method === "set") {
|
|
27
|
+
// A `set` is a STATIC HOLD — a value applied at one point, not an animated
|
|
28
|
+
// keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
|
|
29
|
+
// phantom diamond for a value that doesn't animate. This holds for a base
|
|
30
|
+
// `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
|
|
31
|
+
// path with the runtime scan, which already skips every zero-duration set.
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const toProps = anim.properties;
|
|
35
|
+
const fromProps = anim.fromProperties;
|
|
36
|
+
if (!toProps || Object.keys(toProps).length === 0) return null;
|
|
37
|
+
|
|
38
|
+
const startProps: Record<string, number | string> = {};
|
|
39
|
+
const endProps: Record<string, number | string> = {};
|
|
40
|
+
|
|
41
|
+
if (anim.method === "from") {
|
|
42
|
+
for (const [k, v] of Object.entries(toProps)) {
|
|
43
|
+
startProps[k] = v;
|
|
44
|
+
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
45
|
+
}
|
|
46
|
+
} else if (anim.method === "fromTo" && fromProps) {
|
|
47
|
+
Object.assign(startProps, fromProps);
|
|
48
|
+
Object.assign(endProps, toProps);
|
|
49
|
+
} else {
|
|
50
|
+
for (const [k, v] of Object.entries(toProps)) {
|
|
51
|
+
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
52
|
+
endProps[k] = v;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
format: "percentage",
|
|
58
|
+
keyframes: [
|
|
59
|
+
{ percentage: 0, properties: startProps },
|
|
60
|
+
{ percentage: 100, properties: endProps },
|
|
61
|
+
],
|
|
62
|
+
...(anim.ease ? { ease: anim.ease } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
15
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
16
16
|
import type { SetPatchProps } from "./gsapRuntimePatch";
|
|
17
17
|
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
|
18
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
19
|
+
import { roundTo3 } from "../utils/rounding";
|
|
18
20
|
|
|
19
21
|
interface CommitAnimatedPropertyDeps {
|
|
20
22
|
selectedGsapAnimations: GsapAnimation[];
|
|
@@ -45,14 +47,22 @@ function pickBestAnimation(
|
|
|
45
47
|
selector: string | null,
|
|
46
48
|
property?: string,
|
|
47
49
|
): GsapAnimation | undefined {
|
|
48
|
-
if (animations.length <= 1) return animations[0];
|
|
49
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
50
50
|
const targetGroup = property ? classifyPropertyGroup(property) : undefined;
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
51
|
+
// Group-aware: never hand back a tween from a DIFFERENT property group. The old
|
|
52
|
+
// `animations.length <= 1` early return merged a rotation/3D edit into the element's
|
|
53
|
+
// only tween even when that was a `position` tween — contaminating it and leaving the
|
|
54
|
+
// new property with no clean keyframe baseline. When a target group is known, only
|
|
55
|
+
// same-group tweens are candidates; if none exist we return undefined and the caller
|
|
56
|
+
// creates a fresh same-group tween.
|
|
57
|
+
const candidates =
|
|
58
|
+
targetGroup !== undefined
|
|
59
|
+
? animations.filter((a) => a.propertyGroup === targetGroup)
|
|
60
|
+
: animations;
|
|
61
|
+
if (candidates.length === 0) return undefined;
|
|
62
|
+
if (candidates.length === 1) return candidates[0];
|
|
63
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
64
|
+
const scored = candidates.map((a) => {
|
|
54
65
|
let score = 0;
|
|
55
|
-
if (targetGroup && a.propertyGroup === targetGroup) score += 20;
|
|
56
66
|
if (a.keyframes) score += 10;
|
|
57
67
|
if (selector && a.targetSelector === selector) score += 5;
|
|
58
68
|
else if (a.targetSelector.includes(",")) score -= 3;
|
|
@@ -196,14 +206,15 @@ async function commitKeyframeProps(
|
|
|
196
206
|
iframe: HTMLIFrameElement | null,
|
|
197
207
|
commit: Commit,
|
|
198
208
|
): Promise<void> {
|
|
199
|
-
|
|
209
|
+
const wasKeyframed = !!anim.keyframes;
|
|
210
|
+
if (!wasKeyframed) {
|
|
200
211
|
await commit(
|
|
201
212
|
selection,
|
|
202
213
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
203
214
|
{ label: "Convert to keyframes", skipReload: true },
|
|
204
215
|
);
|
|
205
216
|
}
|
|
206
|
-
const
|
|
217
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
207
218
|
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
208
219
|
const properties: Record<string, number | string> = { ...runtimeProps, ...props };
|
|
209
220
|
|
|
@@ -216,6 +227,52 @@ async function commitKeyframeProps(
|
|
|
216
227
|
backfillDefaults[property] = value;
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
// Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it
|
|
231
|
+
// and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe.
|
|
232
|
+
// The add-keyframe below only writes WITHIN the existing range, so without this a
|
|
233
|
+
// depth edit past the tween end just overwrites the last keyframe (the bug: no new
|
|
234
|
+
// diamond appears at a playhead beyond the tween). Only for an already-keyframed
|
|
235
|
+
// tween — a freshly-converted set has no prior range worth remapping.
|
|
236
|
+
const kfs = anim.keyframes?.keyframes;
|
|
237
|
+
const ts = resolveTweenStart(anim);
|
|
238
|
+
const td = resolveTweenDuration(anim);
|
|
239
|
+
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
240
|
+
const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
241
|
+
const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe;
|
|
242
|
+
if (willExtend && kfs && ts !== null) {
|
|
243
|
+
const newStart = Math.min(ct, ts);
|
|
244
|
+
const newEnd = Math.max(ct, ts + td);
|
|
245
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
246
|
+
const remapped = kfs.map((kf) => {
|
|
247
|
+
const absTime = ts + (kf.percentage / 100) * td;
|
|
248
|
+
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
249
|
+
const p: Record<string, number | string> = { ...kf.properties };
|
|
250
|
+
for (const k of Object.keys(properties)) {
|
|
251
|
+
if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
|
|
252
|
+
}
|
|
253
|
+
return { percentage: newPct, properties: p };
|
|
254
|
+
});
|
|
255
|
+
remapped.push({
|
|
256
|
+
percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10,
|
|
257
|
+
properties,
|
|
258
|
+
});
|
|
259
|
+
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
260
|
+
await commit(
|
|
261
|
+
selection,
|
|
262
|
+
{
|
|
263
|
+
type: "replace-with-keyframes",
|
|
264
|
+
animationId: anim.id,
|
|
265
|
+
targetSelector: anim.targetSelector,
|
|
266
|
+
position: roundTo3(newStart),
|
|
267
|
+
duration: roundTo3(newDuration),
|
|
268
|
+
keyframes: remapped,
|
|
269
|
+
},
|
|
270
|
+
{ label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const pct = computeElementPercentage(ct, selection, anim);
|
|
219
276
|
const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
|
|
220
277
|
// Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
|
|
221
278
|
// rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
|
|
@@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
275
332
|
// so the rejection doesn't escape as an uncaught promise, and bump the cache
|
|
276
333
|
// so selectedGsapAnimations re-syncs and the user's next edit self-heals.
|
|
277
334
|
try {
|
|
278
|
-
//
|
|
279
|
-
//
|
|
335
|
+
// Animated element → keyframe at the playhead, EXACTLY like manual drag /
|
|
336
|
+
// resize / rotate: if the picked anim is still a static `set`,
|
|
337
|
+
// commitKeyframeProps converts it to keyframes first, then writes the new
|
|
338
|
+
// value as a keyframe at the current time — so the 3D animates instead of
|
|
339
|
+
// holding a flat constant. This MUST come before the `set`-update path below,
|
|
340
|
+
// or a 3D `set` would short-circuit to an in-place update and the playhead
|
|
341
|
+
// keyframe would never land (the bug: scrolling depth on a keyframed element
|
|
342
|
+
// just changed the constant instead of dropping a keyframe).
|
|
343
|
+
if (elementHasKeyframes && anim) {
|
|
344
|
+
await commitKeyframeProps(
|
|
345
|
+
selection,
|
|
346
|
+
anim,
|
|
347
|
+
props,
|
|
348
|
+
propEntries,
|
|
349
|
+
primaryProp,
|
|
350
|
+
selector,
|
|
351
|
+
iframe,
|
|
352
|
+
gsapCommitMutation,
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Existing static hold on a NON-animated element — merge the props into the
|
|
358
|
+
// `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
|
|
280
359
|
if (anim?.method === "set") {
|
|
281
360
|
await commitSetProps(
|
|
282
361
|
selection,
|
|
@@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
289
368
|
return;
|
|
290
369
|
}
|
|
291
370
|
|
|
292
|
-
// Static element — persist as a `tl.set`, never
|
|
293
|
-
// no-animation case, which
|
|
371
|
+
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
|
|
372
|
+
// keyframes (incl. the no-animation case, which creates a fresh set).
|
|
294
373
|
if (!elementHasKeyframes) {
|
|
295
374
|
await commitStaticSet(
|
|
296
375
|
selection,
|
|
@@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
302
381
|
return;
|
|
303
382
|
}
|
|
304
383
|
|
|
305
|
-
// Animated element
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
384
|
+
// Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D
|
|
385
|
+
// keyframe on an element that only has a position tween). Create a fresh
|
|
386
|
+
// same-group keyframed tween WITH a 0% baseline at the playhead, instead of
|
|
387
|
+
// contaminating a foreign-group tween. Mirror an existing keyframed tween's
|
|
388
|
+
// time range so the new group animates over the same span. The 0% baseline is
|
|
389
|
+
// an `_auto` endpoint so it tracks the nearest keyframe as you add more.
|
|
390
|
+
if (selector) {
|
|
391
|
+
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
|
392
|
+
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
|
393
|
+
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
|
394
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
395
|
+
const pct =
|
|
396
|
+
tDur > 0
|
|
397
|
+
? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10))
|
|
398
|
+
: 0;
|
|
399
|
+
const newProps = Object.fromEntries(propEntries);
|
|
400
|
+
const keyframes =
|
|
401
|
+
pct <= 0.05
|
|
402
|
+
? [{ percentage: 0, properties: newProps }]
|
|
403
|
+
: [
|
|
404
|
+
{ percentage: 0, properties: { ...newProps, _auto: 1 } },
|
|
405
|
+
{ percentage: pct, properties: newProps },
|
|
406
|
+
];
|
|
407
|
+
await gsapCommitMutation(
|
|
408
|
+
selection,
|
|
409
|
+
{
|
|
410
|
+
type: "add-with-keyframes",
|
|
411
|
+
targetSelector: selector,
|
|
412
|
+
position: roundTo3(tStart),
|
|
413
|
+
duration: roundTo3(tDur),
|
|
414
|
+
keyframes,
|
|
415
|
+
},
|
|
416
|
+
{ label: `Add ${primaryProp} keyframe`, softReload: true },
|
|
417
|
+
);
|
|
309
418
|
return;
|
|
310
419
|
}
|
|
311
|
-
|
|
312
|
-
selection,
|
|
313
|
-
anim,
|
|
314
|
-
props,
|
|
315
|
-
propEntries,
|
|
316
|
-
primaryProp,
|
|
317
|
-
selector,
|
|
318
|
-
iframe,
|
|
319
|
-
gsapCommitMutation,
|
|
320
|
-
);
|
|
420
|
+
bumpGsapCache();
|
|
321
421
|
} catch {
|
|
322
422
|
bumpGsapCache();
|
|
323
423
|
}
|