Skip to content

Commit c6c515d

Browse files
committed
feat(studio): motion-path geometry + commit helpers
Pure path building, nearest-point projection, mutation-payload construction, selection + preview-visibility helpers. Fully unit-tested, no JSX.
1 parent 4c38428 commit c6c515d

6 files changed

Lines changed: 502 additions & 0 deletions

File tree

packages/studio/src/components/editor/domEditOverlayGeometry.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,66 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
2525
return isElementVisibleThroughAncestors(el);
2626
}
2727

28+
// Sample points (as fractions of the element box) for the occlusion hit-test.
29+
const OCCLUSION_SAMPLE_POINTS: ReadonlyArray<readonly [number, number]> = [
30+
[0.5, 0.5],
31+
[0.2, 0.2],
32+
[0.8, 0.2],
33+
[0.2, 0.8],
34+
[0.8, 0.8],
35+
];
36+
37+
/** Cumulative opacity of an element through its ancestors (0 if any link is ~0). */
38+
function effectiveOpacity(el: Element | null, win: Window): number {
39+
let opacity = 1;
40+
let current: Element | null = el;
41+
while (current) {
42+
const op = Number.parseFloat(win.getComputedStyle(current).opacity);
43+
if (Number.isFinite(op)) opacity *= op;
44+
if (opacity <= 0.01) return 0;
45+
current = current.parentElement;
46+
}
47+
return opacity;
48+
}
49+
50+
/**
51+
* True when the element is actually painted on screen — what the viewer sees in
52+
* the preview. Extends `isElementVisibleForOverlay` (display/visibility/opacity)
53+
* with an OCCLUSION test: this composition stacks scenes by z-index and fades them
54+
* IN (never out), so an earlier scene's element stays opacity-1 yet is covered by a
55+
* later opaque scene.
56+
*
57+
* Walks the painted stack (`elementsFromPoint`, top→bottom) at several sample points.
58+
* A point "sees" the element if the element (or its subtree/ancestor) is reached
59+
* before any unrelated element that's effectively opaque. Transparent covers (a
60+
* faded-in scene still at opacity ~0) are skipped — they hit-test but don't paint.
61+
* If every sampled point is blocked by an opaque cover, the element is hidden.
62+
*/
63+
export function isElementVisibleInPreview(el: HTMLElement): boolean {
64+
if (!isElementVisibleForOverlay(el)) return false;
65+
const doc = el.ownerDocument;
66+
const win = doc.defaultView;
67+
if (!win || typeof doc.elementsFromPoint !== "function") return true;
68+
const rect = el.getBoundingClientRect();
69+
if (rect.width <= 0 || rect.height <= 0) return false;
70+
71+
let sampledInViewport = false;
72+
for (const [fx, fy] of OCCLUSION_SAMPLE_POINTS) {
73+
const x = rect.left + rect.width * fx;
74+
const y = rect.top + rect.height * fy;
75+
if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) continue;
76+
sampledInViewport = true;
77+
for (const hit of doc.elementsFromPoint(x, y)) {
78+
if (hit === el || el.contains(hit) || hit.contains(el)) return true; // reached, uncovered
79+
if (effectiveOpacity(hit, win) > 0.01) break; // opaque cover above → this point blocked
80+
// transparent cover (e.g. a scene at opacity ~0) → ignore, keep descending
81+
}
82+
}
83+
// Every in-viewport sample was blocked by an opaque cover → occluded. If nothing
84+
// was testable (off-viewport), don't hide on this basis.
85+
return !sampledInViewport;
86+
}
87+
2888
function readPositiveDimension(value: string | null): number | null {
2989
if (!value) return null;
3090
const parsed = Number.parseFloat(value);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3+
import { editableAnimationId } from "./motionPathSelection";
4+
import {
5+
commitNode,
6+
commitAddWaypoint,
7+
commitRemoveWaypoint,
8+
commitCreatePath,
9+
} from "./motionPathCommit";
10+
11+
const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
12+
({
13+
id: "a1",
14+
targetSelector: "#el",
15+
method: "to",
16+
position: 0,
17+
properties: {},
18+
...over,
19+
}) as GsapAnimation;
20+
21+
describe("editableAnimationId", () => {
22+
it("picks the arc animation for an arc path", () => {
23+
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
24+
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
25+
});
26+
27+
it("picks a position-keyframe animation for a linear path", () => {
28+
const kf = anim({
29+
id: "kf1",
30+
propertyGroup: "position",
31+
keyframes: {
32+
format: "percentage",
33+
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
34+
} as never,
35+
});
36+
expect(editableAnimationId([kf], "linear")).toBe("kf1");
37+
});
38+
39+
it("returns null for dynamic (unresolved) tweens — read-only", () => {
40+
const dyn = anim({
41+
id: "dyn",
42+
arcPath: { enabled: true, autoRotate: false, segments: [] },
43+
hasUnresolvedKeyframes: true,
44+
});
45+
expect(editableAnimationId([dyn], "arc")).toBeNull();
46+
});
47+
48+
it("returns null for non-literal (helper) provenance — read-only", () => {
49+
const helper = anim({
50+
id: "h",
51+
arcPath: { enabled: true, autoRotate: false, segments: [] },
52+
provenance: { kind: "helper" } as never,
53+
});
54+
expect(editableAnimationId([helper], "arc")).toBeNull();
55+
});
56+
57+
it("returns null when nothing matches", () => {
58+
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
59+
});
60+
});
61+
62+
describe("commitNode", () => {
63+
it("routes a keyframe node to update-keyframe by percentage", async () => {
64+
const commit = vi.fn().mockResolvedValue(undefined);
65+
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
66+
expect(commit).toHaveBeenCalledWith(
67+
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
68+
expect.objectContaining({ softReload: true }),
69+
);
70+
});
71+
72+
it("routes a waypoint node to update-motion-path-point by index", async () => {
73+
const commit = vi.fn().mockResolvedValue(undefined);
74+
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
75+
expect(commit).toHaveBeenCalledWith(
76+
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
77+
expect.objectContaining({ softReload: true }),
78+
);
79+
});
80+
});
81+
82+
describe("commitAddWaypoint / commitRemoveWaypoint", () => {
83+
it("adds a waypoint at an index with coordinates", async () => {
84+
const commit = vi.fn().mockResolvedValue(undefined);
85+
await commitAddWaypoint("a1", 1, 120, -40, commit);
86+
expect(commit).toHaveBeenCalledWith(
87+
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
88+
expect.objectContaining({ softReload: true }),
89+
);
90+
});
91+
92+
it("removes a waypoint by index", async () => {
93+
const commit = vi.fn().mockResolvedValue(undefined);
94+
await commitRemoveWaypoint("a1", 2, commit);
95+
expect(commit).toHaveBeenCalledWith(
96+
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
97+
expect.objectContaining({ softReload: true }),
98+
);
99+
});
100+
});
101+
102+
describe("commitCreatePath", () => {
103+
it("authors a new motionPath to a destination at a given time", async () => {
104+
const commit = vi.fn().mockResolvedValue(undefined);
105+
await commitCreatePath("#title", 2.0, 300, -120, commit);
106+
expect(commit).toHaveBeenCalledWith(
107+
{
108+
type: "add-motion-path",
109+
targetSelector: "#title",
110+
position: 2.0,
111+
duration: 1.5,
112+
x: 300,
113+
y: -120,
114+
},
115+
expect.objectContaining({ softReload: true }),
116+
);
117+
});
118+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 commitRemoveWaypoint(
46+
animationId: string,
47+
index: number,
48+
commit: CommitFn,
49+
): Promise<void> {
50+
return commit(
51+
{ type: "remove-motion-path-point", animationId, index },
52+
{ label: "Remove waypoint", softReload: true },
53+
);
54+
}
55+
56+
export function commitCreatePath(
57+
targetSelector: string,
58+
position: number,
59+
x: number,
60+
y: number,
61+
commit: CommitFn,
62+
): Promise<void> {
63+
return commit(
64+
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
65+
{ label: "Create motion path", softReload: true },
66+
);
67+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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", () => {
105+
const p = nearestPointOnPath(50, 20, nodes);
106+
expect(p).toEqual({ x: 50, y: 0, segIndex: 0, dist: 20 });
107+
});
108+
109+
it("picks the second segment when closer to it", () => {
110+
const p = nearestPointOnPath(120, 50, nodes);
111+
expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 });
112+
});
113+
114+
it("clamps to an endpoint when the projection falls past the segment", () => {
115+
const p = nearestPointOnPath(-40, -10, nodes);
116+
expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 });
117+
});
118+
119+
it("returns null for fewer than two nodes", () => {
120+
expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
121+
});
122+
});

0 commit comments

Comments
 (0)