@hyperframes/studio 0.6.91 → 0.6.93
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-DSLrl2tB.js → index-BkwsVKGA.js} +24 -24
- package/dist/assets/index-DYRWmfMX.js +251 -0
- package/dist/assets/index-rm9tn9nH.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioPreviewArea.tsx +48 -13
- package/src/components/TimelineToolbar.tsx +0 -21
- package/src/components/editor/DomEditOverlay.tsx +79 -0
- package/src/components/editor/PropertyPanel.tsx +19 -13
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.test.ts +5 -5
- package/src/components/editor/manualEditingAvailability.ts +11 -7
- package/src/components/editor/manualEditsDom.ts +25 -5
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
- package/src/components/editor/manualEditsDomPatches.ts +17 -1
- package/src/components/editor/manualEditsSnapshot.ts +16 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
- package/src/components/editor/useOffScreenIndicators.ts +197 -0
- package/src/components/nle/NLELayout.tsx +16 -14
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +119 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
- package/src/hooks/gsapRuntimeBridge.ts +266 -41
- package/src/hooks/gsapRuntimeReaders.ts +16 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +13 -0
- package/src/hooks/useEnableKeyframes.ts +3 -1
- package/src/hooks/useGestureCommit.ts +99 -13
- package/src/hooks/useGestureRecording.ts +18 -2
- package/src/hooks/useGsapScriptCommits.ts +24 -3
- package/src/hooks/useGsapSelectionHandlers.ts +19 -3
- package/src/hooks/useGsapTweenCache.ts +30 -10
- package/src/hooks/useRazorSplit.ts +2 -7
- package/src/player/components/ClipContextMenu.tsx +9 -4
- package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
- package/src/player/components/Timeline.tsx +7 -3
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/globalTimeCompiler.test.ts +2 -2
- package/src/utils/globalTimeCompiler.ts +2 -1
- package/src/utils/gsapSoftReload.test.ts +16 -0
- package/src/utils/gsapSoftReload.ts +43 -8
- package/src/utils/rdpSimplify.ts +3 -2
- package/src/utils/timelineElementSplit.test.ts +50 -0
- package/src/utils/timelineElementSplit.ts +16 -0
- package/dist/assets/index-CgYcO2PV.js +0 -146
- package/dist/assets/index-D2NkPomd.css +0 -1
|
@@ -277,14 +277,34 @@ export function applyStudioPathOffsetDraft(
|
|
|
277
277
|
|
|
278
278
|
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
|
|
279
279
|
if (isGsapAnimated) {
|
|
280
|
-
// For GSAP-animated elements: use gsap.set for positioning (the timeline
|
|
281
|
-
// is paused during drag). Set translate:none explicitly to prevent
|
|
282
|
-
// double-counting with the transform.
|
|
283
280
|
element.style.setProperty("translate", "none");
|
|
284
281
|
const win = element.ownerDocument.defaultView as
|
|
285
|
-
| (Window & {
|
|
282
|
+
| (Window & {
|
|
283
|
+
gsap?: {
|
|
284
|
+
set: (el: Element, vars: Record<string, unknown>) => void;
|
|
285
|
+
getProperty: (el: Element, prop: string) => number;
|
|
286
|
+
};
|
|
287
|
+
})
|
|
286
288
|
| null;
|
|
287
|
-
win?.gsap
|
|
289
|
+
if (win?.gsap) {
|
|
290
|
+
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
291
|
+
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
292
|
+
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
|
|
293
|
+
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
|
|
294
|
+
const gsapBaseX = Number.isFinite(baseX)
|
|
295
|
+
? baseX
|
|
296
|
+
: (win.gsap.getProperty(element, "x") as number);
|
|
297
|
+
const gsapBaseY = Number.isFinite(baseY)
|
|
298
|
+
? baseY
|
|
299
|
+
: (win.gsap.getProperty(element, "y") as number);
|
|
300
|
+
if (!Number.isFinite(baseX))
|
|
301
|
+
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
|
|
302
|
+
if (!Number.isFinite(baseY))
|
|
303
|
+
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
|
|
304
|
+
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
|
|
305
|
+
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
|
|
306
|
+
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
|
|
307
|
+
}
|
|
288
308
|
} else {
|
|
289
309
|
// Non-GSAP elements: use CSS translate as before.
|
|
290
310
|
element.style.setProperty(
|
|
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
|
|
|
72
72
|
|
|
73
73
|
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
|
|
74
74
|
const ops: PatchOperation[] = [];
|
|
75
|
-
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP
|
|
75
|
+
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
|
|
76
|
+
// When GSAP owns the element's transform, the live inline translate is kept
|
|
77
|
+
// at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
|
|
78
|
+
// Persist the var() expression in that case, so a reload re-folds the offset.
|
|
79
|
+
const inlineTranslate = element.style.getPropertyValue("translate");
|
|
80
|
+
const hasOffsetVars =
|
|
81
|
+
element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
|
|
82
|
+
element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
|
83
|
+
const translateValue =
|
|
84
|
+
inlineTranslate && inlineTranslate !== "none"
|
|
85
|
+
? inlineTranslate
|
|
86
|
+
: hasOffsetVars
|
|
87
|
+
? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
|
|
88
|
+
: null;
|
|
89
|
+
if (translateValue) {
|
|
90
|
+
ops.push({ type: "inline-style", property: "translate", value: translateValue });
|
|
91
|
+
}
|
|
76
92
|
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
|
|
77
93
|
collectAttributeOps(
|
|
78
94
|
element,
|
|
@@ -183,6 +183,22 @@ export function restoreStudioPathOffset(
|
|
|
183
183
|
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
|
|
184
184
|
previous.originalInlineTranslate,
|
|
185
185
|
);
|
|
186
|
+
|
|
187
|
+
// Restore GSAP x/y if a draft was applied via gsap.set during drag
|
|
188
|
+
const baseX = element.getAttribute("data-hf-drag-gsap-base-x");
|
|
189
|
+
const baseY = element.getAttribute("data-hf-drag-gsap-base-y");
|
|
190
|
+
if (baseX != null || baseY != null) {
|
|
191
|
+
const win = element.ownerDocument.defaultView as
|
|
192
|
+
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
|
|
193
|
+
| null;
|
|
194
|
+
if (win?.gsap) {
|
|
195
|
+
const x = Number.parseFloat(baseX ?? "0") || 0;
|
|
196
|
+
const y = Number.parseFloat(baseY ?? "0") || 0;
|
|
197
|
+
win.gsap.set(element, { x, y });
|
|
198
|
+
}
|
|
199
|
+
element.removeAttribute("data-hf-drag-gsap-base-x");
|
|
200
|
+
element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
201
|
+
}
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
/* ── Clear functions ──────────────────────────────────────────────── */
|
|
@@ -13,6 +13,7 @@ type KeyframeEntry = Array<{
|
|
|
13
13
|
interface PropertyPanel3dTransformProps {
|
|
14
14
|
gsapRuntimeValues: Record<string, number>;
|
|
15
15
|
gsapAnimId: string | null;
|
|
16
|
+
resolveAnimIdForProp?: (prop: string) => string | null;
|
|
16
17
|
gsapKeyframes: KeyframeEntry;
|
|
17
18
|
currentPct: number;
|
|
18
19
|
elStart: number;
|
|
@@ -31,6 +32,7 @@ interface PropertyPanel3dTransformProps {
|
|
|
31
32
|
export function PropertyPanel3dTransform({
|
|
32
33
|
gsapRuntimeValues,
|
|
33
34
|
gsapAnimId,
|
|
35
|
+
resolveAnimIdForProp,
|
|
34
36
|
gsapKeyframes,
|
|
35
37
|
currentPct,
|
|
36
38
|
elStart,
|
|
@@ -41,6 +43,7 @@ export function PropertyPanel3dTransform({
|
|
|
41
43
|
onRemoveKeyframe,
|
|
42
44
|
onConvertToKeyframes,
|
|
43
45
|
}: PropertyPanel3dTransformProps) {
|
|
46
|
+
const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
|
|
44
47
|
return (
|
|
45
48
|
<div className="mt-3 border-t border-neutral-800/40 pt-3">
|
|
46
49
|
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
|
@@ -72,8 +75,14 @@ export function PropertyPanel3dTransform({
|
|
|
72
75
|
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
|
|
73
76
|
}
|
|
74
77
|
}}
|
|
75
|
-
onRemoveKeyframe={(pct) =>
|
|
76
|
-
|
|
78
|
+
onRemoveKeyframe={(pct) => {
|
|
79
|
+
const id = idFor("z");
|
|
80
|
+
if (id) onRemoveKeyframe?.(id, pct);
|
|
81
|
+
}}
|
|
82
|
+
onConvertToKeyframes={() => {
|
|
83
|
+
const id = idFor("z");
|
|
84
|
+
if (id) onConvertToKeyframes?.(id);
|
|
85
|
+
}}
|
|
77
86
|
/>
|
|
78
87
|
)}
|
|
79
88
|
</div>
|
|
@@ -102,8 +111,14 @@ export function PropertyPanel3dTransform({
|
|
|
102
111
|
void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
|
|
103
112
|
}
|
|
104
113
|
}}
|
|
105
|
-
onRemoveKeyframe={(pct) =>
|
|
106
|
-
|
|
114
|
+
onRemoveKeyframe={(pct) => {
|
|
115
|
+
const id = idFor("scale");
|
|
116
|
+
if (id) onRemoveKeyframe?.(id, pct);
|
|
117
|
+
}}
|
|
118
|
+
onConvertToKeyframes={() => {
|
|
119
|
+
const id = idFor("scale");
|
|
120
|
+
if (id) onConvertToKeyframes?.(id);
|
|
121
|
+
}}
|
|
107
122
|
/>
|
|
108
123
|
)}
|
|
109
124
|
</div>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects GSAP-animated elements whose center is outside the visible composition
|
|
3
|
+
* area and returns edge-clamped indicator positions for each.
|
|
4
|
+
*/
|
|
5
|
+
import { useRef, useState, type RefObject } from "react";
|
|
6
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
7
|
+
|
|
8
|
+
export interface OffScreenIndicator {
|
|
9
|
+
key: string;
|
|
10
|
+
elementId: string;
|
|
11
|
+
left: number;
|
|
12
|
+
top: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CompRect {
|
|
18
|
+
left: number;
|
|
19
|
+
top: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
scaleX: number;
|
|
23
|
+
scaleY: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
27
|
+
|
|
28
|
+
function isHtmlElement(node: unknown): node is HTMLElement {
|
|
29
|
+
return (
|
|
30
|
+
typeof node === "object" &&
|
|
31
|
+
node !== null &&
|
|
32
|
+
typeof (node as HTMLElement).getBoundingClientRect === "function" &&
|
|
33
|
+
typeof (node as HTMLElement).tagName === "string"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
|
|
38
|
+
const win = iframe.contentWindow as
|
|
39
|
+
| (Window & { __timelines?: Record<string, TimelineLike> })
|
|
40
|
+
| null;
|
|
41
|
+
if (!win) return [];
|
|
42
|
+
|
|
43
|
+
let timelines: Record<string, TimelineLike> | undefined;
|
|
44
|
+
try {
|
|
45
|
+
timelines = win.__timelines;
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!timelines) return [];
|
|
50
|
+
|
|
51
|
+
const seen = new Set<HTMLElement>();
|
|
52
|
+
for (const tl of Object.values(timelines)) {
|
|
53
|
+
if (!tl?.getChildren) continue;
|
|
54
|
+
try {
|
|
55
|
+
for (const child of tl.getChildren(true)) {
|
|
56
|
+
if (!child.targets) continue;
|
|
57
|
+
for (const t of child.targets()) {
|
|
58
|
+
if (isHtmlElement(t)) seen.add(t);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// cross-origin or detached timeline — skip
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Array.from(seen);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
|
|
69
|
+
if (a.length !== b.length) return false;
|
|
70
|
+
for (let i = 0; i < a.length; i++) {
|
|
71
|
+
const ai = a[i]!;
|
|
72
|
+
const bi = b[i]!;
|
|
73
|
+
if (
|
|
74
|
+
ai.key !== bi.key ||
|
|
75
|
+
Math.abs(ai.left - bi.left) > 0.5 ||
|
|
76
|
+
Math.abs(ai.top - bi.top) > 0.5 ||
|
|
77
|
+
Math.abs(ai.width - bi.width) > 0.5 ||
|
|
78
|
+
Math.abs(ai.height - bi.height) > 0.5
|
|
79
|
+
)
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useOffScreenIndicators({
|
|
86
|
+
iframeRef,
|
|
87
|
+
overlayRef,
|
|
88
|
+
compRect,
|
|
89
|
+
}: {
|
|
90
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
91
|
+
overlayRef: RefObject<HTMLDivElement | null>;
|
|
92
|
+
compRect: CompRect;
|
|
93
|
+
}): OffScreenIndicator[] {
|
|
94
|
+
const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
|
|
95
|
+
const prevRef = useRef<OffScreenIndicator[]>([]);
|
|
96
|
+
const compRectRef = useRef(compRect);
|
|
97
|
+
compRectRef.current = compRect;
|
|
98
|
+
|
|
99
|
+
useMountEffect(() => {
|
|
100
|
+
let frame = 0;
|
|
101
|
+
|
|
102
|
+
const update = () => {
|
|
103
|
+
frame = requestAnimationFrame(update);
|
|
104
|
+
|
|
105
|
+
const iframe = iframeRef.current;
|
|
106
|
+
const overlayEl = overlayRef.current;
|
|
107
|
+
const cr = compRectRef.current;
|
|
108
|
+
if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
|
|
109
|
+
if (prevRef.current.length > 0) {
|
|
110
|
+
prevRef.current = [];
|
|
111
|
+
setIndicators([]);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
117
|
+
const overlayRect = overlayEl.getBoundingClientRect();
|
|
118
|
+
|
|
119
|
+
const doc = iframe.contentDocument;
|
|
120
|
+
const root =
|
|
121
|
+
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
|
122
|
+
if (!root) return;
|
|
123
|
+
|
|
124
|
+
const declaredWidth =
|
|
125
|
+
Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
|
|
126
|
+
const declaredHeight =
|
|
127
|
+
Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
|
|
128
|
+
const rootScaleX = iframeRect.width / declaredWidth;
|
|
129
|
+
const rootScaleY = iframeRect.height / declaredHeight;
|
|
130
|
+
|
|
131
|
+
const targets = collectGsapTargetElements(iframe);
|
|
132
|
+
if (targets.length === 0) {
|
|
133
|
+
if (prevRef.current.length > 0) {
|
|
134
|
+
prevRef.current = [];
|
|
135
|
+
setIndicators([]);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Composition bounds in overlay coordinates
|
|
141
|
+
const compLeft = cr.left;
|
|
142
|
+
const compTop = cr.top;
|
|
143
|
+
const compRight = compLeft + cr.width;
|
|
144
|
+
const compBottom = compTop + cr.height;
|
|
145
|
+
|
|
146
|
+
const next: OffScreenIndicator[] = [];
|
|
147
|
+
const keyCounts = new Map<string, number>();
|
|
148
|
+
|
|
149
|
+
for (const el of targets) {
|
|
150
|
+
if (!el.isConnected) continue;
|
|
151
|
+
|
|
152
|
+
const elRect = el.getBoundingClientRect();
|
|
153
|
+
if (elRect.width <= 0 && elRect.height <= 0) continue;
|
|
154
|
+
|
|
155
|
+
// Element rect in overlay coordinates
|
|
156
|
+
const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
|
|
157
|
+
const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
|
|
158
|
+
const elW = elRect.width * rootScaleX;
|
|
159
|
+
const elH = elRect.height * rootScaleY;
|
|
160
|
+
|
|
161
|
+
// Check if the element is fully inside the composition
|
|
162
|
+
if (
|
|
163
|
+
elLeft >= compLeft &&
|
|
164
|
+
elTop >= compTop &&
|
|
165
|
+
elLeft + elW <= compRight &&
|
|
166
|
+
elTop + elH <= compBottom
|
|
167
|
+
) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only elements with a real id attribute can be selected via getElementById
|
|
172
|
+
if (!el.id) continue;
|
|
173
|
+
const count = keyCounts.get(el.id) ?? 0;
|
|
174
|
+
keyCounts.set(el.id, count + 1);
|
|
175
|
+
const key = count > 0 ? `${el.id}:${count}` : el.id;
|
|
176
|
+
next.push({
|
|
177
|
+
key,
|
|
178
|
+
elementId: el.id,
|
|
179
|
+
left: elLeft,
|
|
180
|
+
top: elTop,
|
|
181
|
+
width: elW,
|
|
182
|
+
height: elH,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!indicatorsEqual(prevRef.current, next)) {
|
|
187
|
+
prevRef.current = next;
|
|
188
|
+
setIndicators(next);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
frame = requestAnimationFrame(update);
|
|
193
|
+
return () => cancelAnimationFrame(frame);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return indicators;
|
|
197
|
+
}
|
|
@@ -366,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
|
|
|
366
366
|
{/* Preview + player controls */}
|
|
367
367
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
368
368
|
<div
|
|
369
|
-
className="flex-1 min-h-0 relative
|
|
369
|
+
className="flex-1 min-h-0 relative"
|
|
370
370
|
data-preview-pan-surface="true"
|
|
371
371
|
onDragOver={handlePreviewDragOver}
|
|
372
372
|
onDragLeave={handlePreviewDragLeave}
|
|
373
373
|
onDrop={handlePreviewDrop}
|
|
374
374
|
>
|
|
375
|
-
<
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
375
|
+
<div className="absolute inset-0 overflow-hidden">
|
|
376
|
+
<NLEPreview
|
|
377
|
+
projectId={projectId}
|
|
378
|
+
iframeRef={iframeRef}
|
|
379
|
+
onIframeLoad={onIframeLoad}
|
|
380
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
381
|
+
portrait={portrait}
|
|
382
|
+
directUrl={directUrl}
|
|
383
|
+
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
|
384
|
+
onStageRef={handleStageRef}
|
|
385
|
+
/>
|
|
386
|
+
{previewDragOver && (
|
|
387
|
+
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
388
390
|
{!isFullscreen && previewOverlay}
|
|
389
391
|
</div>
|
|
390
392
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
3
|
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
|
3
4
|
|
|
@@ -60,6 +61,7 @@ export function DomEditProvider({
|
|
|
60
61
|
handleGsapUpdateProperty,
|
|
61
62
|
handleGsapUpdateMeta,
|
|
62
63
|
handleGsapDeleteAnimation,
|
|
64
|
+
handleGsapDeleteAllForElement,
|
|
63
65
|
handleGsapAddAnimation,
|
|
64
66
|
handleGsapAddProperty,
|
|
65
67
|
handleGsapRemoveProperty,
|
|
@@ -133,6 +135,7 @@ export function DomEditProvider({
|
|
|
133
135
|
handleGsapUpdateProperty,
|
|
134
136
|
handleGsapUpdateMeta,
|
|
135
137
|
handleGsapDeleteAnimation,
|
|
138
|
+
handleGsapDeleteAllForElement,
|
|
136
139
|
handleGsapAddAnimation,
|
|
137
140
|
handleGsapAddProperty,
|
|
138
141
|
handleGsapRemoveProperty,
|
|
@@ -200,6 +203,7 @@ export function DomEditProvider({
|
|
|
200
203
|
handleGsapUpdateProperty,
|
|
201
204
|
handleGsapUpdateMeta,
|
|
202
205
|
handleGsapDeleteAnimation,
|
|
206
|
+
handleGsapDeleteAllForElement,
|
|
203
207
|
handleGsapAddAnimation,
|
|
204
208
|
handleGsapAddProperty,
|
|
205
209
|
handleGsapRemoveProperty,
|
|
@@ -11,8 +11,6 @@ import {
|
|
|
11
11
|
resolveTweenStart,
|
|
12
12
|
resolveTweenDuration,
|
|
13
13
|
} from "../utils/globalTimeCompiler";
|
|
14
|
-
import { readAllAnimatedProperties } from "./gsapRuntimeReaders";
|
|
15
|
-
|
|
16
14
|
export interface GsapDragCommitCallbacks {
|
|
17
15
|
commitMutation: (
|
|
18
16
|
selection: DomEditSelection,
|
|
@@ -25,6 +23,7 @@ export interface GsapDragCommitCallbacks {
|
|
|
25
23
|
beforeReload?: () => void;
|
|
26
24
|
},
|
|
27
25
|
) => Promise<void>;
|
|
26
|
+
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
// ── Percentage computation ─────────────────────────────────────────────────
|
|
@@ -114,7 +113,6 @@ async function extendTweenAndAddKeyframe(
|
|
|
114
113
|
const newStart = Math.min(targetTime, tweenStart);
|
|
115
114
|
const newEnd = Math.max(targetTime, tweenEnd);
|
|
116
115
|
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
117
|
-
|
|
118
116
|
const existingKfs = anim.keyframes?.keyframes ?? [];
|
|
119
117
|
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
120
118
|
[];
|
|
@@ -126,20 +124,15 @@ async function extendTweenAndAddKeyframe(
|
|
|
126
124
|
|
|
127
125
|
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
|
128
126
|
remappedKfs.push({ percentage: targetPct, properties });
|
|
129
|
-
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
selection,
|
|
133
|
-
{ type: "delete", animationId: anim.id },
|
|
134
|
-
{ label: "Extend tween range", skipReload: true },
|
|
135
|
-
);
|
|
128
|
+
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
|
136
129
|
|
|
137
|
-
const selector = anim.targetSelector;
|
|
138
130
|
await callbacks.commitMutation(
|
|
139
131
|
selection,
|
|
140
132
|
{
|
|
141
|
-
type: "
|
|
142
|
-
|
|
133
|
+
type: "replace-with-keyframes",
|
|
134
|
+
animationId: anim.id,
|
|
135
|
+
targetSelector: anim.targetSelector,
|
|
143
136
|
position: Math.round(newStart * 1000) / 1000,
|
|
144
137
|
duration: Math.round(newDuration * 1000) / 1000,
|
|
145
138
|
keyframes: remappedKfs,
|
|
@@ -156,8 +149,8 @@ async function commitKeyframedPosition(
|
|
|
156
149
|
callbacks: GsapDragCommitCallbacks,
|
|
157
150
|
beforeReload?: () => void,
|
|
158
151
|
): Promise<void> {
|
|
159
|
-
const
|
|
160
|
-
|
|
152
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
153
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
161
154
|
await callbacks.commitMutation(
|
|
162
155
|
selection,
|
|
163
156
|
{
|
|
@@ -168,6 +161,7 @@ async function commitKeyframedPosition(
|
|
|
168
161
|
},
|
|
169
162
|
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
170
163
|
);
|
|
164
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
171
165
|
}
|
|
172
166
|
|
|
173
167
|
/**
|
|
@@ -182,10 +176,11 @@ async function commitFlatViaKeyframes(
|
|
|
182
176
|
callbacks: GsapDragCommitCallbacks,
|
|
183
177
|
beforeReload?: () => void,
|
|
184
178
|
): Promise<void> {
|
|
179
|
+
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
185
180
|
await callbacks.commitMutation(
|
|
186
181
|
selection,
|
|
187
182
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
188
|
-
{ label: "Convert to keyframes for drag", skipReload: true },
|
|
183
|
+
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
189
184
|
);
|
|
190
185
|
|
|
191
186
|
const pct = computeCurrentPercentage(selection, anim);
|
|
@@ -198,7 +193,7 @@ async function commitFlatViaKeyframes(
|
|
|
198
193
|
percentage: pct,
|
|
199
194
|
properties,
|
|
200
195
|
},
|
|
201
|
-
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
196
|
+
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
|
|
202
197
|
);
|
|
203
198
|
}
|
|
204
199
|
|
|
@@ -243,19 +238,20 @@ export async function commitGsapPositionFromDrag(
|
|
|
243
238
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
244
239
|
};
|
|
245
240
|
|
|
241
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
246
242
|
if (anim.keyframes) {
|
|
247
243
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
248
244
|
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
|
249
|
-
const
|
|
245
|
+
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
250
246
|
|
|
251
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
252
247
|
const ts = resolveTweenStart(effectiveAnim);
|
|
253
248
|
const td = resolveTweenDuration(effectiveAnim);
|
|
254
|
-
|
|
249
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
250
|
+
if (outsideRange) {
|
|
255
251
|
await extendTweenAndAddKeyframe(
|
|
256
252
|
selection,
|
|
257
253
|
effectiveAnim,
|
|
258
|
-
|
|
254
|
+
dragProps,
|
|
259
255
|
ct,
|
|
260
256
|
ts,
|
|
261
257
|
td,
|
|
@@ -263,32 +259,112 @@ export async function commitGsapPositionFromDrag(
|
|
|
263
259
|
restoreOffset,
|
|
264
260
|
);
|
|
265
261
|
} else {
|
|
266
|
-
await commitKeyframedPosition(
|
|
262
|
+
await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
|
|
263
|
+
}
|
|
264
|
+
} else if (anim.method === "from" || anim.method === "fromTo") {
|
|
265
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
266
|
+
const ts = resolveTweenStart(anim);
|
|
267
|
+
const td = resolveTweenDuration(anim);
|
|
268
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
269
|
+
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
270
|
+
|
|
271
|
+
if (outsideRange && ts !== null) {
|
|
272
|
+
// Split the original from() tween into property groups first.
|
|
273
|
+
await callbacks.commitMutation(
|
|
267
274
|
selection,
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
{ type: "split-into-property-groups", animationId: anim.id },
|
|
276
|
+
{ label: "Split from() for drag", skipReload: true },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
|
280
|
+
const existingPosAnim = allAnims.find(
|
|
281
|
+
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (existingPosAnim?.keyframes) {
|
|
285
|
+
// Extend the existing position tween
|
|
286
|
+
const posTs = resolveTweenStart(existingPosAnim);
|
|
287
|
+
const posTd = resolveTweenDuration(existingPosAnim);
|
|
288
|
+
if (posTs !== null) {
|
|
289
|
+
await extendTweenAndAddKeyframe(
|
|
290
|
+
selection,
|
|
291
|
+
existingPosAnim,
|
|
292
|
+
{ x: newX, y: newY },
|
|
293
|
+
ct,
|
|
294
|
+
posTs,
|
|
295
|
+
posTd,
|
|
296
|
+
callbacks,
|
|
297
|
+
restoreOffset,
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// No existing position tween — create one
|
|
304
|
+
const newStart = Math.min(ct, ts);
|
|
305
|
+
const newEnd = Math.max(ct, ts + td);
|
|
306
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
307
|
+
const dragBefore = ct < ts;
|
|
308
|
+
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
|
|
309
|
+
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
|
|
310
|
+
|
|
311
|
+
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
312
|
+
[];
|
|
313
|
+
if (dragBefore) {
|
|
314
|
+
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
|
|
315
|
+
if (origStartPct > 0.5 && origStartPct < 99.5) {
|
|
316
|
+
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
|
|
317
|
+
}
|
|
318
|
+
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
|
|
319
|
+
} else {
|
|
320
|
+
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
|
|
321
|
+
if (origEndPct > 0.5 && origEndPct < 99.5) {
|
|
322
|
+
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
|
|
323
|
+
}
|
|
324
|
+
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
|
|
325
|
+
}
|
|
326
|
+
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
327
|
+
|
|
328
|
+
await callbacks.commitMutation(
|
|
329
|
+
selection,
|
|
330
|
+
{
|
|
331
|
+
type: "add-with-keyframes",
|
|
332
|
+
targetSelector: anim.targetSelector,
|
|
333
|
+
position: Math.round(newStart * 1000) / 1000,
|
|
334
|
+
duration: Math.round(newDuration * 1000) / 1000,
|
|
335
|
+
keyframes,
|
|
336
|
+
},
|
|
337
|
+
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
|
|
338
|
+
);
|
|
339
|
+
} else {
|
|
340
|
+
// Inside tween range: convert then add keyframe at current time
|
|
341
|
+
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
342
|
+
await callbacks.commitMutation(
|
|
343
|
+
selection,
|
|
344
|
+
{
|
|
345
|
+
type: "convert-to-keyframes",
|
|
346
|
+
animationId: anim.id,
|
|
347
|
+
},
|
|
348
|
+
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
|
|
349
|
+
);
|
|
350
|
+
const pct = computeCurrentPercentage(selection, anim);
|
|
351
|
+
await callbacks.commitMutation(
|
|
352
|
+
selection,
|
|
353
|
+
{
|
|
354
|
+
type: "add-keyframe",
|
|
355
|
+
animationId: anim.id,
|
|
356
|
+
percentage: pct,
|
|
357
|
+
properties: dragProps,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
label: `Move layer (keyframe ${pct}%)`,
|
|
361
|
+
softReload: true,
|
|
362
|
+
beforeReload: restoreOffset,
|
|
363
|
+
coalesceKey,
|
|
364
|
+
},
|
|
272
365
|
);
|
|
273
366
|
}
|
|
274
|
-
} else if (anim.method === "from" || anim.method === "fromTo") {
|
|
275
|
-
await callbacks.commitMutation(
|
|
276
|
-
selection,
|
|
277
|
-
{
|
|
278
|
-
type: "convert-to-keyframes",
|
|
279
|
-
animationId: anim.id,
|
|
280
|
-
resolvedFromValues: { x: newX, y: newY },
|
|
281
|
-
},
|
|
282
|
-
{ label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
|
|
283
|
-
);
|
|
284
367
|
} else {
|
|
285
|
-
|
|
286
|
-
await commitFlatViaKeyframes(
|
|
287
|
-
selection,
|
|
288
|
-
anim,
|
|
289
|
-
{ ...runtimeProps, x: newX, y: newY },
|
|
290
|
-
callbacks,
|
|
291
|
-
restoreOffset,
|
|
292
|
-
);
|
|
368
|
+
await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
|
|
293
369
|
}
|
|
294
370
|
}
|