@hyperframes/studio 0.6.121 → 0.7.0
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-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-20PAVS4k.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
|
|
3
|
+
* GSAP source mutation routed through the (selection-bound) commit facade, which
|
|
4
|
+
* handles the soft reload, undo snapshot, and save-failure feedback.
|
|
5
|
+
*/
|
|
6
|
+
import type { MotionNodeRef } from "./motionPathGeometry";
|
|
7
|
+
|
|
8
|
+
export type CommitFn = (
|
|
9
|
+
mutation: Record<string, unknown>,
|
|
10
|
+
options: { label: string; softReload?: boolean },
|
|
11
|
+
) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
const NEW_PATH_DURATION = 1.5;
|
|
14
|
+
|
|
15
|
+
export function commitNode(
|
|
16
|
+
ref: MotionNodeRef,
|
|
17
|
+
x: number,
|
|
18
|
+
y: number,
|
|
19
|
+
animationId: string,
|
|
20
|
+
commit: CommitFn,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const mutation: Record<string, unknown> =
|
|
23
|
+
ref.type === "keyframe"
|
|
24
|
+
? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
|
|
25
|
+
: { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
|
|
26
|
+
return commit(mutation, {
|
|
27
|
+
label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
|
|
28
|
+
softReload: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function commitAddWaypoint(
|
|
33
|
+
animationId: string,
|
|
34
|
+
index: number,
|
|
35
|
+
x: number,
|
|
36
|
+
y: number,
|
|
37
|
+
commit: CommitFn,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
return commit(
|
|
40
|
+
{ type: "add-motion-path-point", animationId, index, x, y },
|
|
41
|
+
{ label: "Add waypoint", softReload: true },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function commitAddKeyframe(
|
|
46
|
+
animationId: string,
|
|
47
|
+
percentage: number,
|
|
48
|
+
x: number,
|
|
49
|
+
y: number,
|
|
50
|
+
commit: CommitFn,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
// percentage is tween-relative (matches MotionNodeRef.keyframe.pct). The parser's
|
|
53
|
+
// addKeyframeToScript inserts a new "P%": { x, y } stop (or merges if one exists
|
|
54
|
+
// at that pct) and converts a flat tween to keyframes form when needed.
|
|
55
|
+
return commit(
|
|
56
|
+
{ type: "add-keyframe", animationId, percentage, properties: { x, y } },
|
|
57
|
+
{ label: "Add keyframe", softReload: true },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function commitRemoveWaypoint(
|
|
62
|
+
animationId: string,
|
|
63
|
+
index: number,
|
|
64
|
+
commit: CommitFn,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
return commit(
|
|
67
|
+
{ type: "remove-motion-path-point", animationId, index },
|
|
68
|
+
{ label: "Remove waypoint", softReload: true },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function commitCreatePath(
|
|
73
|
+
targetSelector: string,
|
|
74
|
+
position: number,
|
|
75
|
+
x: number,
|
|
76
|
+
y: number,
|
|
77
|
+
commit: CommitFn,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
return commit(
|
|
80
|
+
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
|
|
81
|
+
{ label: "Create motion path", softReload: true },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
|
|
3
|
+
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
4
|
+
|
|
5
|
+
const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
|
|
6
|
+
|
|
7
|
+
describe("buildMotionPathGeometry", () => {
|
|
8
|
+
it("builds a linear path with keyframe-ref nodes from an x/y tween", () => {
|
|
9
|
+
const read: ReadTween = { keyframes: [kf(0, 10, 20), kf(100, 200, 80)] };
|
|
10
|
+
const geo = buildMotionPathGeometry(read);
|
|
11
|
+
expect(geo).not.toBeNull();
|
|
12
|
+
expect(geo!.kind).toBe("linear");
|
|
13
|
+
expect(geo!.points).toBe("10,20 200,80");
|
|
14
|
+
expect(geo!.nodes).toEqual([
|
|
15
|
+
{ x: 10, y: 20, ref: { type: "keyframe", pct: 0 } },
|
|
16
|
+
{ x: 200, y: 80, ref: { type: "keyframe", pct: 100 } },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("preserves order and percentages for intermediate keyframes", () => {
|
|
21
|
+
const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] };
|
|
22
|
+
const geo = buildMotionPathGeometry(read);
|
|
23
|
+
expect(geo!.nodes.map((n) => n.ref)).toEqual([
|
|
24
|
+
{ type: "keyframe", pct: 0 },
|
|
25
|
+
{ type: "keyframe", pct: 50 },
|
|
26
|
+
{ type: "keyframe", pct: 100 },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("builds an arc path with waypoint-index refs when arcPath is present", () => {
|
|
31
|
+
const read: ReadTween = {
|
|
32
|
+
keyframes: [kf(0, 0, 0), kf(50, 60, 40), kf(100, 120, 10)],
|
|
33
|
+
arcPath: { enabled: true, autoRotate: false, segments: [{ curviness: 1 }, { curviness: 1 }] },
|
|
34
|
+
};
|
|
35
|
+
const geo = buildMotionPathGeometry(read);
|
|
36
|
+
expect(geo!.kind).toBe("arc");
|
|
37
|
+
expect(geo!.nodes.map((n) => n.ref)).toEqual([
|
|
38
|
+
{ type: "waypoint", index: 0 },
|
|
39
|
+
{ type: "waypoint", index: 1 },
|
|
40
|
+
{ type: "waypoint", index: 2 },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null for a tween with no positional keyframes", () => {
|
|
45
|
+
const read: ReadTween = {
|
|
46
|
+
keyframes: [
|
|
47
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
48
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
expect(buildMotionPathGeometry(read)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("draws a single-axis (x-only) tween, defaulting the missing axis to 0", () => {
|
|
55
|
+
// Regression: an `x`-only tween (e.g. `to({ x: -260 })`) carries no `y`, so the
|
|
56
|
+
// builder used to skip every node → no path until the user added the 2nd axis.
|
|
57
|
+
const read: ReadTween = {
|
|
58
|
+
keyframes: [
|
|
59
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
60
|
+
{ percentage: 100, properties: { x: -260 } },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const geo = buildMotionPathGeometry(read);
|
|
64
|
+
expect(geo).not.toBeNull();
|
|
65
|
+
expect(geo!.points).toBe("0,0 -260,0"); // y defaults to 0 → horizontal path
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("draws a y-only tween too (x defaults to 0)", () => {
|
|
69
|
+
const read: ReadTween = {
|
|
70
|
+
keyframes: [
|
|
71
|
+
{ percentage: 0, properties: { y: 0 } },
|
|
72
|
+
{ percentage: 100, properties: { y: 500 } },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
expect(buildMotionPathGeometry(read)!.points).toBe("0,0 0,500");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("excludes keyframes missing a coordinate without throwing", () => {
|
|
79
|
+
const read: ReadTween = {
|
|
80
|
+
keyframes: [kf(0, 10, 20), { percentage: 50, properties: { x: 100 } }, kf(100, 200, 80)],
|
|
81
|
+
};
|
|
82
|
+
const geo = buildMotionPathGeometry(read);
|
|
83
|
+
expect(geo!.nodes).toHaveLength(2);
|
|
84
|
+
expect(geo!.points).toBe("10,20 200,80");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null when fewer than two valid nodes remain", () => {
|
|
88
|
+
const read: ReadTween = { keyframes: [kf(0, 10, 20)] };
|
|
89
|
+
expect(buildMotionPathGeometry(read)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null for null input", () => {
|
|
93
|
+
expect(buildMotionPathGeometry(null)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("nearestPointOnPath", () => {
|
|
98
|
+
const nodes = [
|
|
99
|
+
{ x: 0, y: 0 },
|
|
100
|
+
{ x: 100, y: 0 },
|
|
101
|
+
{ x: 100, y: 100 },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
it("projects onto the nearest segment and reports its index + fraction", () => {
|
|
105
|
+
const p = nearestPointOnPath(50, 20, nodes);
|
|
106
|
+
expect(p).toEqual({ x: 50, y: 0, segIndex: 0, t: 0.5, dist: 20 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("reports t at the segment endpoints (0 at start, clamps to 1 past the end)", () => {
|
|
110
|
+
expect(nearestPointOnPath(0, 5, nodes)).toMatchObject({ segIndex: 0, t: 0 });
|
|
111
|
+
expect(nearestPointOnPath(110, 0, nodes)).toMatchObject({ segIndex: 0, t: 1 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("picks the second segment when closer to it", () => {
|
|
115
|
+
const p = nearestPointOnPath(120, 50, nodes);
|
|
116
|
+
expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("clamps to an endpoint when the projection falls past the segment", () => {
|
|
120
|
+
const p = nearestPointOnPath(-40, -10, nodes);
|
|
121
|
+
expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns null for fewer than two nodes", () => {
|
|
125
|
+
expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a live tween (from `readRuntimeKeyframes`) into renderable motion-path
|
|
3
|
+
* geometry for the on-canvas overlay. Pure — no React/DOM — so it unit-tests in
|
|
4
|
+
* isolation. Coordinates are in composition space (the same space the overlay's
|
|
5
|
+
* viewBox uses), so the caller renders nodes/points directly.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
8
|
+
|
|
9
|
+
/** Which source edit a dragged node maps to. */
|
|
10
|
+
export type MotionNodeRef =
|
|
11
|
+
| { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
|
|
12
|
+
| { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
|
|
13
|
+
|
|
14
|
+
export interface MotionPathNode {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
ref: MotionNodeRef;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MotionPathGeometry {
|
|
21
|
+
/** "linear" = x/y keyframes; "arc" = motionPath tween. */
|
|
22
|
+
kind: "linear" | "arc";
|
|
23
|
+
/** SVG polyline points: "x,y x,y ...". */
|
|
24
|
+
points: string;
|
|
25
|
+
nodes: MotionPathNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build motion-path geometry, or null when the tween carries no positional path
|
|
30
|
+
* (fewer than two keyframes with both x and y). For motionPath tweens the
|
|
31
|
+
* keyframes are the arc waypoints (anchors), index-aligned with the source path
|
|
32
|
+
* — so a waypoint node at index `i` rewrites source waypoint `i`.
|
|
33
|
+
*
|
|
34
|
+
* ponytail: the arc is drawn as a polyline through its waypoints (matching the
|
|
35
|
+
* angular dotted look of the reference), not GSAP's resolved curve. Dense
|
|
36
|
+
* curve sampling is a later refinement if the straight-segment preview proves
|
|
37
|
+
* insufficient.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Nearest point on a polyline to (px, py), with the index of the segment it
|
|
41
|
+
* lies on and `t` = how far along that segment the returned point sits.
|
|
42
|
+
*
|
|
43
|
+
* `t` semantics: clamped to the inclusive range [0, 1].
|
|
44
|
+
* - `t === 0` → the point is at (or projects before) the segment's start node
|
|
45
|
+
* (`segIndex`); a perpendicular dropped from (px, py) falls at or behind `a`.
|
|
46
|
+
* - `0 < t < 1` → the point is strictly interior to the segment.
|
|
47
|
+
* - `t === 1` → the point is at (or projects PAST) the segment's end node
|
|
48
|
+
* (`segIndex + 1`); past-the-end projections are clamped back onto the endpoint,
|
|
49
|
+
* so the returned (x, y) is exactly `nodes[segIndex + 1]`. Callers can read
|
|
50
|
+
* `t === 1` as "snapped to the end anchor of this segment" (equivalently, the
|
|
51
|
+
* start anchor of the next segment).
|
|
52
|
+
* A degenerate zero-length segment (`a === b`) yields `t === 0`.
|
|
53
|
+
*
|
|
54
|
+
* Used to position the ghost "add" node and decide where a new node goes: a
|
|
55
|
+
* motionPath waypoint inserts between `segIndex`/`segIndex + 1`, a keyframe
|
|
56
|
+
* interpolates its tween-% from the two adjacent keyframes via `t`.
|
|
57
|
+
* Coordinates are whatever space the caller passes (overlay uses absolute px).
|
|
58
|
+
*/
|
|
59
|
+
export function nearestPointOnPath(
|
|
60
|
+
px: number,
|
|
61
|
+
py: number,
|
|
62
|
+
nodes: Array<{ x: number; y: number }>,
|
|
63
|
+
): { x: number; y: number; segIndex: number; t: number; dist: number } | null {
|
|
64
|
+
if (nodes.length < 2) return null;
|
|
65
|
+
let best: { x: number; y: number; segIndex: number; t: number; dist: number } | null = null;
|
|
66
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
67
|
+
const a = nodes[i]!;
|
|
68
|
+
const b = nodes[i + 1]!;
|
|
69
|
+
const dx = b.x - a.x;
|
|
70
|
+
const dy = b.y - a.y;
|
|
71
|
+
const len2 = dx * dx + dy * dy;
|
|
72
|
+
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2));
|
|
73
|
+
const cx = a.x + t * dx;
|
|
74
|
+
const cy = a.y + t * dy;
|
|
75
|
+
const dist = Math.hypot(px - cx, py - cy);
|
|
76
|
+
if (!best || dist < best.dist) best = { x: cx, y: cy, segIndex: i, t, dist };
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null {
|
|
82
|
+
if (!read) return null;
|
|
83
|
+
const isArc = Boolean(read.arcPath);
|
|
84
|
+
const nodes: MotionPathNode[] = [];
|
|
85
|
+
|
|
86
|
+
// Index by source position so a waypoint node maps to the matching source
|
|
87
|
+
// anchor. Arc waypoints always carry x/y (never filtered), so source index
|
|
88
|
+
// and node order stay aligned.
|
|
89
|
+
// Which axes does the tween animate at all? A single-axis tween (e.g.
|
|
90
|
+
// `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP
|
|
91
|
+
// transform identity), so we default it and still draw a path. But if the tween
|
|
92
|
+
// DOES animate an axis and a given keyframe omits it, that value is interpolated
|
|
93
|
+
// (not 0) and can't be placed here → skip that node (the prior behavior).
|
|
94
|
+
const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
|
|
95
|
+
const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x));
|
|
96
|
+
const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
|
|
97
|
+
if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
|
|
98
|
+
|
|
99
|
+
read.keyframes.forEach((kf, i) => {
|
|
100
|
+
if (tweenHasX && !finite(kf.properties.x)) return;
|
|
101
|
+
if (tweenHasY && !finite(kf.properties.y)) return;
|
|
102
|
+
nodes.push({
|
|
103
|
+
x: tweenHasX ? (kf.properties.x as number) : 0,
|
|
104
|
+
y: tweenHasY ? (kf.properties.y as number) : 0,
|
|
105
|
+
ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (nodes.length < 2) return null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
kind: isArc ? "arc" : "linear",
|
|
113
|
+
points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
|
|
114
|
+
nodes,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving the selected element and the animation whose path is editable.
|
|
3
|
+
* Shared by the overlay and its diagnostics (kept here to avoid a circular
|
|
4
|
+
* import between the two).
|
|
5
|
+
*/
|
|
6
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
export function selectorFor(sel: DomEditSelection | null): string | null {
|
|
10
|
+
if (!sel) return null;
|
|
11
|
+
if (sel.id) return `#${CSS.escape(sel.id)}`;
|
|
12
|
+
return sel.selector ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The animation whose path is editable on-canvas: literal, statically resolved,
|
|
16
|
+
* and matching the rendered geometry kind. Returns null when the path can only
|
|
17
|
+
* be displayed (dynamic/helper tweens) — those nodes stay read-only. */
|
|
18
|
+
export function editableAnimationId(
|
|
19
|
+
animations: GsapAnimation[],
|
|
20
|
+
kind: "linear" | "arc",
|
|
21
|
+
): string | null {
|
|
22
|
+
const ok = (a: GsapAnimation) =>
|
|
23
|
+
!a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance;
|
|
24
|
+
if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null;
|
|
25
|
+
const a = animations.find(
|
|
26
|
+
(anim) =>
|
|
27
|
+
anim.keyframes &&
|
|
28
|
+
ok(anim) &&
|
|
29
|
+
(anim.propertyGroup === "position" ||
|
|
30
|
+
anim.keyframes.keyframes.some((k) => "x" in k.properties || "y" in k.properties)),
|
|
31
|
+
);
|
|
32
|
+
return a?.id ?? null;
|
|
33
|
+
}
|
|
@@ -9,6 +9,7 @@ import { type DomEditSelection } from "./domEditing";
|
|
|
9
9
|
import {
|
|
10
10
|
applyManualOffsetDragCommit,
|
|
11
11
|
applyManualOffsetDragDraft,
|
|
12
|
+
applyRotationDraftViaGsap,
|
|
12
13
|
endManualOffsetDragMembers,
|
|
13
14
|
restoreManualOffsetDragMembers,
|
|
14
15
|
resumeGsapTimelines,
|
|
@@ -161,19 +162,21 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
161
162
|
let dy = e.clientY - g.startY;
|
|
162
163
|
|
|
163
164
|
if (g.kind === "rotate") {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
165
|
+
// Single source of truth: preview the rotation through the GSAP channel (the
|
|
166
|
+
// same channel the commit lands in), not the `--hf-studio-rotation` CSS var.
|
|
167
|
+
const rotated = resolveDomEditRotationGesture({
|
|
168
|
+
centerX: g.centerX,
|
|
169
|
+
centerY: g.centerY,
|
|
170
|
+
startX: g.startX,
|
|
171
|
+
startY: g.startY,
|
|
172
|
+
currentX: e.clientX,
|
|
173
|
+
currentY: e.clientY,
|
|
174
|
+
actualAngle: g.actualRotation,
|
|
175
|
+
snap: e.shiftKey,
|
|
176
|
+
});
|
|
177
|
+
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
|
|
178
|
+
applyStudioRotationDraft(sel.element, rotated);
|
|
179
|
+
}
|
|
177
180
|
return;
|
|
178
181
|
}
|
|
179
182
|
|
|
@@ -393,25 +396,38 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
393
396
|
actualAngle: g.actualRotation,
|
|
394
397
|
snap: e.shiftKey,
|
|
395
398
|
});
|
|
399
|
+
const restoreRotation = () => {
|
|
400
|
+
// Single source of truth: snap the GSAP rotation back to the gesture's base
|
|
401
|
+
// angle; fall back to the legacy CSS-var restore when gsap is unavailable.
|
|
402
|
+
if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
|
|
403
|
+
restoreStudioRotation(sel.element, g.initialRotation);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
396
406
|
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
|
|
397
|
-
|
|
407
|
+
restoreRotation();
|
|
398
408
|
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
399
409
|
return;
|
|
400
410
|
}
|
|
401
|
-
|
|
411
|
+
// Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
|
|
412
|
+
// while the commit lands a `tl.set`/keyframe rotation on the timeline.
|
|
413
|
+
if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
|
|
414
|
+
applyStudioRotation(sel.element, finalRotation);
|
|
415
|
+
}
|
|
402
416
|
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
|
403
417
|
.catch(() => {
|
|
404
418
|
if (
|
|
405
419
|
g.manualEditDragToken &&
|
|
406
420
|
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
|
407
421
|
)
|
|
408
|
-
|
|
422
|
+
restoreRotation();
|
|
409
423
|
})
|
|
410
424
|
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
|
411
425
|
} else if (g.kind === "drag") {
|
|
412
426
|
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
|
|
413
427
|
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
|
|
414
|
-
if (!g.pathOffsetMember)
|
|
428
|
+
if (!g.pathOffsetMember) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
415
431
|
const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
|
|
416
432
|
const nextBoxLeft = g.originLeft + dx;
|
|
417
433
|
const nextBoxTop = g.originTop + dy;
|
|
@@ -427,7 +443,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
427
443
|
box.style.left = `${nextBoxLeft}px`;
|
|
428
444
|
box.style.top = `${nextBoxTop}px`;
|
|
429
445
|
}
|
|
430
|
-
void Promise.resolve(
|
|
446
|
+
void Promise.resolve(
|
|
447
|
+
opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }),
|
|
448
|
+
)
|
|
431
449
|
.catch(() => {
|
|
432
450
|
if (
|
|
433
451
|
g.pathOffsetMember?.gestureToken &&
|
|
@@ -148,6 +148,12 @@ export function useDomEditOverlayRects({
|
|
|
148
148
|
activeCompositionPathRef.current,
|
|
149
149
|
resolvedElementRef as ResolvedElementRef,
|
|
150
150
|
);
|
|
151
|
+
// An explicitly-selected element's overlay must track it whenever it's laid
|
|
152
|
+
// out and not display:none/visibility:hidden/opacity:0 — use basic visibility,
|
|
153
|
+
// NOT the occlusion heuristic. Occlusion (isElementVisibleInPreview) treats any
|
|
154
|
+
// opacity:1 ancestor as an opaque cover even when it paints nothing (e.g. a
|
|
155
|
+
// backgroundless full-bleed scene above a subcomposition), which would wrongly
|
|
156
|
+
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
|
|
151
157
|
if (el && isElementVisibleForOverlay(el)) {
|
|
152
158
|
const nextRect = toOverlayRect(overlayEl, iframe, el);
|
|
153
159
|
setOverlayRect(nextRect);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
|
|
4
|
+
import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
|
|
5
|
+
|
|
6
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
7
|
+
|
|
8
|
+
export function elementHome(el: HTMLElement): { x: number; y: number } {
|
|
9
|
+
let left = 0;
|
|
10
|
+
let top = 0;
|
|
11
|
+
let node: HTMLElement | null = el;
|
|
12
|
+
while (node) {
|
|
13
|
+
left += node.offsetLeft;
|
|
14
|
+
top += node.offsetTop;
|
|
15
|
+
const parent = node.offsetParent as HTMLElement | null;
|
|
16
|
+
if (!parent || parent.hasAttribute("data-composition-id")) break;
|
|
17
|
+
node = parent;
|
|
18
|
+
}
|
|
19
|
+
let x = left + el.offsetWidth / 2;
|
|
20
|
+
let y = top + el.offsetHeight / 2;
|
|
21
|
+
if ((el.style.translate ?? "").includes("var(")) {
|
|
22
|
+
x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
|
23
|
+
y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
|
24
|
+
}
|
|
25
|
+
return { x, y };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isPreviewHtmlElement(
|
|
29
|
+
node: Element | null | undefined,
|
|
30
|
+
iframe: HTMLIFrameElement | null,
|
|
31
|
+
): node is HTMLElement {
|
|
32
|
+
const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
|
|
33
|
+
?.HTMLElement;
|
|
34
|
+
return Boolean(node && Ctor && node instanceof Ctor);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rectsClose(a: Rect, b: Rect): boolean {
|
|
38
|
+
return (
|
|
39
|
+
Math.abs(a.left - b.left) < 0.5 &&
|
|
40
|
+
Math.abs(a.top - b.top) < 0.5 &&
|
|
41
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
42
|
+
Math.abs(a.height - b.height) < 0.5
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasMotionPathPlugin(iframe: HTMLIFrameElement | null): boolean {
|
|
47
|
+
try {
|
|
48
|
+
return Boolean(
|
|
49
|
+
(iframe?.contentWindow as unknown as { MotionPathPlugin?: unknown })?.MotionPathPlugin,
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useMotionPathData(
|
|
57
|
+
iframeRef: RefObject<HTMLIFrameElement | null>,
|
|
58
|
+
selector: string | null,
|
|
59
|
+
): {
|
|
60
|
+
rect: Rect | null;
|
|
61
|
+
geometry: MotionPathGeometry | null;
|
|
62
|
+
geometryResolved: boolean;
|
|
63
|
+
visibleInPreview: boolean;
|
|
64
|
+
home: { x: number; y: number } | null;
|
|
65
|
+
} {
|
|
66
|
+
const [rect, setRect] = useState<Rect | null>(null);
|
|
67
|
+
const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
|
|
68
|
+
const resolvedForRef = useRef<string | null>(null);
|
|
69
|
+
const geometryResolved = resolvedForRef.current === selector;
|
|
70
|
+
const [visibleInPreview, setVisibleInPreview] = useState(true);
|
|
71
|
+
const [home, setHome] = useState<{ x: number; y: number } | null>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!selector) {
|
|
75
|
+
setRect(null);
|
|
76
|
+
setHome(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setHome(null);
|
|
80
|
+
let raf = 0;
|
|
81
|
+
const tick = () => {
|
|
82
|
+
const el = iframeRef.current;
|
|
83
|
+
if (el) {
|
|
84
|
+
const r = el.getBoundingClientRect();
|
|
85
|
+
const surface = el.ownerDocument?.querySelector("[data-preview-pan-surface]");
|
|
86
|
+
const sRect = surface?.getBoundingClientRect();
|
|
87
|
+
const next = {
|
|
88
|
+
left: sRect ? r.left - sRect.left : r.left,
|
|
89
|
+
top: sRect ? r.top - sRect.top : r.top,
|
|
90
|
+
width: r.width,
|
|
91
|
+
height: r.height,
|
|
92
|
+
};
|
|
93
|
+
setRect((prev) => (prev && rectsClose(prev, next) ? prev : next));
|
|
94
|
+
let target: Element | null = null;
|
|
95
|
+
try {
|
|
96
|
+
target = el.contentDocument?.querySelector(selector) ?? null;
|
|
97
|
+
} catch {
|
|
98
|
+
/* cross-origin guard */
|
|
99
|
+
}
|
|
100
|
+
const live = isPreviewHtmlElement(target, el) ? target : null;
|
|
101
|
+
const vis = live ? isElementVisibleForOverlay(live) : true;
|
|
102
|
+
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
|
|
103
|
+
if (live) {
|
|
104
|
+
const h = elementHome(live);
|
|
105
|
+
setHome((prev) =>
|
|
106
|
+
prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
raf = requestAnimationFrame(tick);
|
|
111
|
+
};
|
|
112
|
+
raf = requestAnimationFrame(tick);
|
|
113
|
+
return () => cancelAnimationFrame(raf);
|
|
114
|
+
}, [selector, iframeRef]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!selector) {
|
|
118
|
+
setGeometry(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const recompute = () => {
|
|
122
|
+
const read = readRuntimeKeyframes(iframeRef.current, selector);
|
|
123
|
+
const next = buildMotionPathGeometry(read);
|
|
124
|
+
setGeometry((prev) =>
|
|
125
|
+
prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
|
|
126
|
+
);
|
|
127
|
+
resolvedForRef.current = selector;
|
|
128
|
+
};
|
|
129
|
+
recompute();
|
|
130
|
+
const id = window.setInterval(recompute, 250);
|
|
131
|
+
return () => window.clearInterval(id);
|
|
132
|
+
}, [selector, iframeRef]);
|
|
133
|
+
|
|
134
|
+
return { rect, geometry, geometryResolved, visibleInPreview, home };
|
|
135
|
+
}
|
|
@@ -16,11 +16,13 @@ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
|
16
16
|
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
17
17
|
import { useCompositionStack } from "./useCompositionStack";
|
|
18
18
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
19
|
+
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
|
19
20
|
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
20
21
|
import {
|
|
21
22
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
22
23
|
getTimelineToggleTitle,
|
|
23
24
|
} from "../../utils/timelineDiscovery";
|
|
25
|
+
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
|
24
26
|
|
|
25
27
|
interface NLELayoutProps {
|
|
26
28
|
projectId: string;
|
|
@@ -122,12 +124,14 @@ export const NLELayout = memo(function NLELayout({
|
|
|
122
124
|
refreshPlayer,
|
|
123
125
|
} = useTimelinePlayer();
|
|
124
126
|
|
|
125
|
-
// Reset timeline state when the project changes
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// Reset timeline state when the project changes. Done in an effect, not during
|
|
128
|
+
// render: reset() updates the player store, and updating another store/component
|
|
129
|
+
// mid-render triggers React's "Cannot update a component while rendering a
|
|
130
|
+
// different component" warning. The effect runs right after commit, so the new
|
|
131
|
+
// project's first frame may briefly show prior timeline state before it clears.
|
|
132
|
+
useEffect(() => {
|
|
129
133
|
usePlayerStore.getState().reset();
|
|
130
|
-
}
|
|
134
|
+
}, [projectId]);
|
|
131
135
|
|
|
132
136
|
const stageRefForDrop = useRef<HTMLDivElement | null>(null);
|
|
133
137
|
const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
|
|
@@ -158,6 +162,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
158
162
|
|
|
159
163
|
const onIframeLoad = useCallback(() => {
|
|
160
164
|
baseOnIframeLoad();
|
|
165
|
+
// Pre-load + register MotionPathPlugin once so adding a motion path in the
|
|
166
|
+
// studio doesn't take the async plugin-load flash path on the first soft
|
|
167
|
+
// reload (the comp may not ship the plugin until it actually uses one).
|
|
168
|
+
ensureMotionPathPluginLoaded(iframeRef.current);
|
|
161
169
|
onIframeRef?.(iframeRef.current);
|
|
162
170
|
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
|
163
171
|
|
|
@@ -294,6 +302,9 @@ export const NLELayout = memo(function NLELayout({
|
|
|
294
302
|
if (id && src) map.set(id, src);
|
|
295
303
|
}
|
|
296
304
|
setCompIdToSrc(map);
|
|
305
|
+
// Let DOM source-resolution recover a subcomposition element's source file
|
|
306
|
+
// (the runtime drops the linkage when inlining — see getSourceFileForElement).
|
|
307
|
+
setCompositionSourceMap(map);
|
|
297
308
|
onCompIdToSrcChange?.(map);
|
|
298
309
|
})
|
|
299
310
|
.catch(() => {});
|
|
@@ -427,7 +438,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
427
438
|
{/* Preview + player controls */}
|
|
428
439
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
429
440
|
<div
|
|
430
|
-
className="flex-1 min-h-0 relative"
|
|
441
|
+
className="flex-1 min-h-0 relative overflow-hidden"
|
|
431
442
|
data-preview-pan-surface="true"
|
|
432
443
|
onPointerDown={(e) => {
|
|
433
444
|
const el = iframeRef.current?.parentElement ?? iframeRef.current;
|