Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/studio/src/components/editor/domEditOverlayGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,66 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
return isElementVisibleThroughAncestors(el);
}

// Sample points (as fractions of the element box) for the occlusion hit-test.
const OCCLUSION_SAMPLE_POINTS: ReadonlyArray<readonly [number, number]> = [
[0.5, 0.5],
[0.2, 0.2],
[0.8, 0.2],
[0.2, 0.8],
[0.8, 0.8],
];

/** Cumulative opacity of an element through its ancestors (0 if any link is ~0). */
function effectiveOpacity(el: Element | null, win: Window): number {
let opacity = 1;
let current: Element | null = el;
while (current) {
const op = Number.parseFloat(win.getComputedStyle(current).opacity);
if (Number.isFinite(op)) opacity *= op;
if (opacity <= 0.01) return 0;
current = current.parentElement;
}
return opacity;
}

/**
* True when the element is actually painted on screen — what the viewer sees in
* the preview. Extends `isElementVisibleForOverlay` (display/visibility/opacity)
* with an OCCLUSION test: this composition stacks scenes by z-index and fades them
* IN (never out), so an earlier scene's element stays opacity-1 yet is covered by a
* later opaque scene.
*
* Walks the painted stack (`elementsFromPoint`, top→bottom) at several sample points.
* A point "sees" the element if the element (or its subtree/ancestor) is reached
* before any unrelated element that's effectively opaque. Transparent covers (a
* faded-in scene still at opacity ~0) are skipped — they hit-test but don't paint.
* If every sampled point is blocked by an opaque cover, the element is hidden.
*/
export function isElementVisibleInPreview(el: HTMLElement): boolean {
if (!isElementVisibleForOverlay(el)) return false;
const doc = el.ownerDocument;
const win = doc.defaultView;
if (!win || typeof doc.elementsFromPoint !== "function") return true;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;

let sampledInViewport = false;
for (const [fx, fy] of OCCLUSION_SAMPLE_POINTS) {
const x = rect.left + rect.width * fx;
const y = rect.top + rect.height * fy;
if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) continue;
sampledInViewport = true;
for (const hit of doc.elementsFromPoint(x, y)) {
if (hit === el || el.contains(hit) || hit.contains(el)) return true; // reached, uncovered
if (effectiveOpacity(hit, win) > 0.01) break; // opaque cover above → this point blocked
// transparent cover (e.g. a scene at opacity ~0) → ignore, keep descending
}
}
// Every in-viewport sample was blocked by an opaque cover → occluded. If nothing
// was testable (off-viewport), don't hide on this basis.
return !sampledInViewport;
}

function readPositiveDimension(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
Expand Down
130 changes: 130 additions & 0 deletions packages/studio/src/components/editor/motionPathCommit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { editableAnimationId } from "./motionPathSelection";
import {
commitNode,
commitAddWaypoint,
commitAddKeyframe,
commitRemoveWaypoint,
commitCreatePath,
} from "./motionPathCommit";

const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
({
id: "a1",
targetSelector: "#el",
method: "to",
position: 0,
properties: {},
...over,
}) as GsapAnimation;

describe("editableAnimationId", () => {
it("picks the arc animation for an arc path", () => {
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
});

it("picks a position-keyframe animation for a linear path", () => {
const kf = anim({
id: "kf1",
propertyGroup: "position",
keyframes: {
format: "percentage",
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
} as never,
});
expect(editableAnimationId([kf], "linear")).toBe("kf1");
});

it("returns null for dynamic (unresolved) tweens — read-only", () => {
const dyn = anim({
id: "dyn",
arcPath: { enabled: true, autoRotate: false, segments: [] },
hasUnresolvedKeyframes: true,
});
expect(editableAnimationId([dyn], "arc")).toBeNull();
});

it("returns null for non-literal (helper) provenance — read-only", () => {
const helper = anim({
id: "h",
arcPath: { enabled: true, autoRotate: false, segments: [] },
provenance: { kind: "helper" } as never,
});
expect(editableAnimationId([helper], "arc")).toBeNull();
});

it("returns null when nothing matches", () => {
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
});
});

describe("commitNode", () => {
it("routes a keyframe node to update-keyframe by percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
expect.objectContaining({ softReload: true }),
);
});

it("routes a waypoint node to update-motion-path-point by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
expect.objectContaining({ softReload: true }),
);
});
});

describe("commitAddWaypoint / commitRemoveWaypoint", () => {
it("adds a waypoint at an index with coordinates", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddWaypoint("a1", 1, 120, -40, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
expect.objectContaining({ softReload: true }),
);
});

it("removes a waypoint by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitRemoveWaypoint("a1", 2, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
expect.objectContaining({ softReload: true }),
);
});
});

describe("commitAddKeyframe", () => {
it("inserts an x/y keyframe at a tween-relative percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddKeyframe("a1", 42.5, 80, -20, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
expect.objectContaining({ softReload: true }),
);
});
});

describe("commitCreatePath", () => {
it("authors a new motionPath to a destination at a given time", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitCreatePath("#title", 2.0, 300, -120, commit);
expect(commit).toHaveBeenCalledWith(
{
type: "add-motion-path",
targetSelector: "#title",
position: 2.0,
duration: 1.5,
x: 300,
y: -120,
},
expect.objectContaining({ softReload: true }),
);
});
});
83 changes: 83 additions & 0 deletions packages/studio/src/components/editor/motionPathCommit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
* GSAP source mutation routed through the (selection-bound) commit facade, which
* handles the soft reload, undo snapshot, and save-failure feedback.
*/
import type { MotionNodeRef } from "./motionPathGeometry";

export type CommitFn = (
mutation: Record<string, unknown>,
options: { label: string; softReload?: boolean },
) => Promise<void>;

const NEW_PATH_DURATION = 1.5;

export function commitNode(
ref: MotionNodeRef,
x: number,
y: number,
animationId: string,
commit: CommitFn,
): Promise<void> {
const mutation: Record<string, unknown> =
ref.type === "keyframe"
? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
: { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
return commit(mutation, {
label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
softReload: true,
});
}

export function commitAddWaypoint(
animationId: string,
index: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "add-motion-path-point", animationId, index, x, y },
{ label: "Add waypoint", softReload: true },
);
}

export function commitAddKeyframe(
animationId: string,
percentage: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
// percentage is tween-relative (matches MotionNodeRef.keyframe.pct). The parser's
// addKeyframeToScript inserts a new "P%": { x, y } stop (or merges if one exists
// at that pct) and converts a flat tween to keyframes form when needed.
return commit(
{ type: "add-keyframe", animationId, percentage, properties: { x, y } },
{ label: "Add keyframe", softReload: true },
);
}

export function commitRemoveWaypoint(
animationId: string,
index: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "remove-motion-path-point", animationId, index },
{ label: "Remove waypoint", softReload: true },
);
}

export function commitCreatePath(
targetSelector: string,
position: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
{ label: "Create motion path", softReload: true },
);
}
Loading
Loading