Skip to content

Commit ce7e60a

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 622ae96 commit ce7e60a

6 files changed

Lines changed: 537 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: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
commitAddKeyframe,
8+
commitRemoveWaypoint,
9+
commitCreatePath,
10+
} from "./motionPathCommit";
11+
12+
const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
13+
({
14+
id: "a1",
15+
targetSelector: "#el",
16+
method: "to",
17+
position: 0,
18+
properties: {},
19+
...over,
20+
}) as GsapAnimation;
21+
22+
describe("editableAnimationId", () => {
23+
it("picks the arc animation for an arc path", () => {
24+
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
25+
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
26+
});
27+
28+
it("picks a position-keyframe animation for a linear path", () => {
29+
const kf = anim({
30+
id: "kf1",
31+
propertyGroup: "position",
32+
keyframes: {
33+
format: "percentage",
34+
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
35+
} as never,
36+
});
37+
expect(editableAnimationId([kf], "linear")).toBe("kf1");
38+
});
39+
40+
it("returns null for dynamic (unresolved) tweens — read-only", () => {
41+
const dyn = anim({
42+
id: "dyn",
43+
arcPath: { enabled: true, autoRotate: false, segments: [] },
44+
hasUnresolvedKeyframes: true,
45+
});
46+
expect(editableAnimationId([dyn], "arc")).toBeNull();
47+
});
48+
49+
it("returns null for non-literal (helper) provenance — read-only", () => {
50+
const helper = anim({
51+
id: "h",
52+
arcPath: { enabled: true, autoRotate: false, segments: [] },
53+
provenance: { kind: "helper" } as never,
54+
});
55+
expect(editableAnimationId([helper], "arc")).toBeNull();
56+
});
57+
58+
it("returns null when nothing matches", () => {
59+
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
60+
});
61+
});
62+
63+
describe("commitNode", () => {
64+
it("routes a keyframe node to update-keyframe by percentage", async () => {
65+
const commit = vi.fn().mockResolvedValue(undefined);
66+
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
67+
expect(commit).toHaveBeenCalledWith(
68+
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
69+
expect.objectContaining({ softReload: true }),
70+
);
71+
});
72+
73+
it("routes a waypoint node to update-motion-path-point by index", async () => {
74+
const commit = vi.fn().mockResolvedValue(undefined);
75+
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
76+
expect(commit).toHaveBeenCalledWith(
77+
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
78+
expect.objectContaining({ softReload: true }),
79+
);
80+
});
81+
});
82+
83+
describe("commitAddWaypoint / commitRemoveWaypoint", () => {
84+
it("adds a waypoint at an index with coordinates", async () => {
85+
const commit = vi.fn().mockResolvedValue(undefined);
86+
await commitAddWaypoint("a1", 1, 120, -40, commit);
87+
expect(commit).toHaveBeenCalledWith(
88+
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
89+
expect.objectContaining({ softReload: true }),
90+
);
91+
});
92+
93+
it("removes a waypoint by index", async () => {
94+
const commit = vi.fn().mockResolvedValue(undefined);
95+
await commitRemoveWaypoint("a1", 2, commit);
96+
expect(commit).toHaveBeenCalledWith(
97+
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
98+
expect.objectContaining({ softReload: true }),
99+
);
100+
});
101+
});
102+
103+
describe("commitAddKeyframe", () => {
104+
it("inserts an x/y keyframe at a tween-relative percentage", async () => {
105+
const commit = vi.fn().mockResolvedValue(undefined);
106+
await commitAddKeyframe("a1", 42.5, 80, -20, commit);
107+
expect(commit).toHaveBeenCalledWith(
108+
{ type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
109+
expect.objectContaining({ softReload: true }),
110+
);
111+
});
112+
});
113+
114+
describe("commitCreatePath", () => {
115+
it("authors a new motionPath to a destination at a given time", async () => {
116+
const commit = vi.fn().mockResolvedValue(undefined);
117+
await commitCreatePath("#title", 2.0, 300, -120, commit);
118+
expect(commit).toHaveBeenCalledWith(
119+
{
120+
type: "add-motion-path",
121+
targetSelector: "#title",
122+
position: 2.0,
123+
duration: 1.5,
124+
x: 300,
125+
y: -120,
126+
},
127+
expect.objectContaining({ softReload: true }),
128+
);
129+
});
130+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)