@hyperframes/studio 0.6.90 → 0.6.92
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-CDy8BuGq.js} +24 -24
- package/dist/assets/index-CmRIkCwI.js +251 -0
- package/dist/assets/index-rm9tn9nH.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +54 -13
- package/src/components/TimelineToolbar.tsx +52 -35
- package/src/components/editor/DomEditOverlay.tsx +79 -0
- package/src/components/editor/PropertyPanel.tsx +19 -10
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +16 -0
- 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 +22 -32
- package/src/components/nle/TimelineEditorNotice.tsx +2 -25
- 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/useAppHotkeys.ts +48 -1
- package/src/hooks/useContextMenuDismiss.ts +29 -0
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +20 -4
- 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 +298 -0
- package/src/hooks/useTimelineEditing.ts +15 -98
- package/src/player/components/ClipContextMenu.tsx +14 -25
- package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
- package/src/player/components/PlayheadIndicator.tsx +43 -0
- package/src/player/components/Timeline.tsx +45 -38
- package/src/player/components/TimelineCanvas.tsx +29 -22
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/components/timelineCallbacks.ts +44 -0
- package/src/player/components/timelineDragDrop.ts +2 -14
- package/src/player/components/useTimelineZoom.ts +18 -0
- package/src/player/store/playerStore.ts +20 -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 +32 -0
- package/dist/assets/index-BKuDHMYl.js +0 -146
- package/dist/assets/index-D2NkPomd.css +0 -1
|
@@ -1,7 +1,37 @@
|
|
|
1
|
+
// GSAP's CSSPlugin takes ownership of the element's entire transform stack
|
|
2
|
+
// when it tweens ANY of these — it bakes the CSS `translate` longhand into
|
|
3
|
+
// style.transform at init and writes `translate: none` every tick. Position
|
|
4
|
+
// reapply/strip logic must therefore stand down for all of them, not just x/y.
|
|
5
|
+
const GSAP_TRANSFORM_PROPS = [
|
|
6
|
+
"x",
|
|
7
|
+
"y",
|
|
8
|
+
"xPercent",
|
|
9
|
+
"yPercent",
|
|
10
|
+
"scale",
|
|
11
|
+
"scaleX",
|
|
12
|
+
"scaleY",
|
|
13
|
+
"rotation",
|
|
14
|
+
"rotate",
|
|
15
|
+
"rotationX",
|
|
16
|
+
"rotationY",
|
|
17
|
+
"skewX",
|
|
18
|
+
"skewY",
|
|
19
|
+
"transform",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when GSAP animates any transform-affecting property on the element,
|
|
24
|
+
* meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
|
|
25
|
+
*/
|
|
26
|
+
export function gsapAnimatesTransform(el: HTMLElement): boolean {
|
|
27
|
+
return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
/**
|
|
2
31
|
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
|
|
3
32
|
* the given element by inspecting all registered `__timelines`.
|
|
4
33
|
*/
|
|
34
|
+
// fallow-ignore-next-line complexity
|
|
5
35
|
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
|
|
6
36
|
const win = el.ownerDocument.defaultView as
|
|
7
37
|
| (Window & {
|
|
@@ -25,6 +25,18 @@ describe("manual editing availability", () => {
|
|
|
25
25
|
expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
it("disables GSAP drag intercept by default", async () => {
|
|
29
|
+
const availability = await loadAvailabilityWithEnv({});
|
|
30
|
+
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("enables GSAP drag intercept when env var is set", async () => {
|
|
34
|
+
const availability = await loadAvailabilityWithEnv({
|
|
35
|
+
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "true",
|
|
36
|
+
});
|
|
37
|
+
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
28
40
|
it("disables preview selection when the inspector panel flag is explicitly off", async () => {
|
|
29
41
|
const availability = await loadAvailabilityWithEnv({
|
|
30
42
|
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
|
|
@@ -77,6 +77,22 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
77
77
|
true,
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
+
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
81
|
+
env,
|
|
82
|
+
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
|
87
|
+
// persist path instead of being intercepted into GSAP script keyframe
|
|
88
|
+
// mutations. The keyframe intercept rewrites timeline tweens from drag
|
|
89
|
+
// gestures and is opt-in until its recording path is hardened.
|
|
90
|
+
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
91
|
+
env,
|
|
92
|
+
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
|
|
80
96
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
81
97
|
|
|
82
98
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
11
11
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
12
12
|
import type { TimelineElement } from "../../player";
|
|
13
|
-
import type {
|
|
13
|
+
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
|
14
14
|
import { NLEPreview } from "./NLEPreview";
|
|
15
15
|
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
16
16
|
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getTimelineToggleTitle,
|
|
21
21
|
} from "../../utils/timelineDiscovery";
|
|
22
22
|
|
|
23
|
-
interface NLELayoutProps {
|
|
23
|
+
interface NLELayoutProps extends TimelineEditCallbacks {
|
|
24
24
|
projectId: string;
|
|
25
25
|
portrait?: boolean;
|
|
26
26
|
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
|
@@ -59,23 +59,7 @@ interface NLELayoutProps {
|
|
|
59
59
|
blockName: string,
|
|
60
60
|
position: { left: number; top: number },
|
|
61
61
|
) => Promise<void> | void;
|
|
62
|
-
/** Persist timeline move actions back into source HTML */
|
|
63
|
-
onMoveElement?: (
|
|
64
|
-
element: TimelineElement,
|
|
65
|
-
updates: Pick<TimelineElement, "start" | "track">,
|
|
66
|
-
) => Promise<void> | void;
|
|
67
|
-
onResizeElement?: (
|
|
68
|
-
element: TimelineElement,
|
|
69
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
70
|
-
) => Promise<void> | void;
|
|
71
|
-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
|
-
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
62
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
74
|
-
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
75
|
-
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
76
|
-
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
77
|
-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
78
|
-
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
79
63
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
80
64
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
81
65
|
/** Whether the timeline panel is visible (default: true) */
|
|
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
124
108
|
onResizeElement,
|
|
125
109
|
onBlockedEditAttempt,
|
|
126
110
|
onSplitElement,
|
|
111
|
+
onRazorSplit,
|
|
112
|
+
onRazorSplitAll,
|
|
127
113
|
onSelectTimelineElement,
|
|
128
114
|
onDeleteKeyframe,
|
|
129
115
|
onDeleteAllKeyframes,
|
|
@@ -380,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
|
|
|
380
366
|
{/* Preview + player controls */}
|
|
381
367
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
382
368
|
<div
|
|
383
|
-
className="flex-1 min-h-0 relative
|
|
369
|
+
className="flex-1 min-h-0 relative"
|
|
384
370
|
data-preview-pan-surface="true"
|
|
385
371
|
onDragOver={handlePreviewDragOver}
|
|
386
372
|
onDragLeave={handlePreviewDragLeave}
|
|
387
373
|
onDrop={handlePreviewDrop}
|
|
388
374
|
>
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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>
|
|
402
390
|
{!isFullscreen && previewOverlay}
|
|
403
391
|
</div>
|
|
404
392
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
@@ -460,6 +448,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
460
448
|
onResizeElement={onResizeElement}
|
|
461
449
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
462
450
|
onSplitElement={onSplitElement}
|
|
451
|
+
onRazorSplit={onRazorSplit}
|
|
452
|
+
onRazorSplitAll={onRazorSplitAll}
|
|
463
453
|
onSelectElement={onSelectTimelineElement}
|
|
464
454
|
onDeleteKeyframe={onDeleteKeyframe}
|
|
465
455
|
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
|
|
2
|
+
import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
|
|
2
3
|
|
|
3
4
|
interface TimelineEditorNoticeProps {
|
|
4
5
|
onDismiss: () => void;
|
|
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
|
|
|
76
77
|
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
|
77
78
|
}}
|
|
78
79
|
>
|
|
79
|
-
<
|
|
80
|
-
className="absolute top-0 bottom-0"
|
|
81
|
-
style={{
|
|
82
|
-
left: "50%",
|
|
83
|
-
width: 2,
|
|
84
|
-
marginLeft: -1,
|
|
85
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
86
|
-
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
87
|
-
}}
|
|
88
|
-
/>
|
|
89
|
-
<div
|
|
90
|
-
className="absolute"
|
|
91
|
-
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
|
92
|
-
>
|
|
93
|
-
<div
|
|
94
|
-
style={{
|
|
95
|
-
width: 0,
|
|
96
|
-
height: 0,
|
|
97
|
-
borderLeft: "6px solid transparent",
|
|
98
|
-
borderRight: "6px solid transparent",
|
|
99
|
-
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
100
|
-
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
101
|
-
}}
|
|
102
|
-
/>
|
|
103
|
-
</div>
|
|
80
|
+
<PlayheadIndicator />
|
|
104
81
|
</div>
|
|
105
82
|
|
|
106
83
|
<div className="flex flex-col gap-1.5">
|
|
@@ -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,
|