From 7f979c553b4f5928119e80151eb4ee687bb6b2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 18 Jun 2026 10:41:38 -0400 Subject: [PATCH] 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. --- .../editor/domEditOverlayGeometry.ts | 60 ++++++++ .../editor/motionPathCommit.test.ts | 130 ++++++++++++++++++ .../src/components/editor/motionPathCommit.ts | 83 +++++++++++ .../editor/motionPathGeometry.test.ts | 127 +++++++++++++++++ .../components/editor/motionPathGeometry.ts | 104 ++++++++++++++ .../components/editor/motionPathSelection.ts | 33 +++++ 6 files changed, 537 insertions(+) create mode 100644 packages/studio/src/components/editor/motionPathCommit.test.ts create mode 100644 packages/studio/src/components/editor/motionPathCommit.ts create mode 100644 packages/studio/src/components/editor/motionPathGeometry.test.ts create mode 100644 packages/studio/src/components/editor/motionPathGeometry.ts create mode 100644 packages/studio/src/components/editor/motionPathSelection.ts diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 991d1f910..20435e0ae 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -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 = [ + [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); diff --git a/packages/studio/src/components/editor/motionPathCommit.test.ts b/packages/studio/src/components/editor/motionPathCommit.test.ts new file mode 100644 index 000000000..6de7acdba --- /dev/null +++ b/packages/studio/src/components/editor/motionPathCommit.test.ts @@ -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 => + ({ + 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 }), + ); + }); +}); diff --git a/packages/studio/src/components/editor/motionPathCommit.ts b/packages/studio/src/components/editor/motionPathCommit.ts new file mode 100644 index 000000000..53a7c0fcf --- /dev/null +++ b/packages/studio/src/components/editor/motionPathCommit.ts @@ -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, + options: { label: string; softReload?: boolean }, +) => Promise; + +const NEW_PATH_DURATION = 1.5; + +export function commitNode( + ref: MotionNodeRef, + x: number, + y: number, + animationId: string, + commit: CommitFn, +): Promise { + const mutation: Record = + 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 { + 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 { + // 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 { + 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 { + return commit( + { type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y }, + { label: "Create motion path", softReload: true }, + ); +} diff --git a/packages/studio/src/components/editor/motionPathGeometry.test.ts b/packages/studio/src/components/editor/motionPathGeometry.test.ts new file mode 100644 index 000000000..fc6696683 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathGeometry.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry"; +import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes"; + +const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } }); + +describe("buildMotionPathGeometry", () => { + it("builds a linear path with keyframe-ref nodes from an x/y tween", () => { + const read: ReadTween = { keyframes: [kf(0, 10, 20), kf(100, 200, 80)] }; + const geo = buildMotionPathGeometry(read); + expect(geo).not.toBeNull(); + expect(geo!.kind).toBe("linear"); + expect(geo!.points).toBe("10,20 200,80"); + expect(geo!.nodes).toEqual([ + { x: 10, y: 20, ref: { type: "keyframe", pct: 0 } }, + { x: 200, y: 80, ref: { type: "keyframe", pct: 100 } }, + ]); + }); + + it("preserves order and percentages for intermediate keyframes", () => { + const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] }; + const geo = buildMotionPathGeometry(read); + expect(geo!.nodes.map((n) => n.ref)).toEqual([ + { type: "keyframe", pct: 0 }, + { type: "keyframe", pct: 50 }, + { type: "keyframe", pct: 100 }, + ]); + }); + + it("builds an arc path with waypoint-index refs when arcPath is present", () => { + const read: ReadTween = { + keyframes: [kf(0, 0, 0), kf(50, 60, 40), kf(100, 120, 10)], + arcPath: { enabled: true, autoRotate: false, segments: [{ curviness: 1 }, { curviness: 1 }] }, + }; + const geo = buildMotionPathGeometry(read); + expect(geo!.kind).toBe("arc"); + expect(geo!.nodes.map((n) => n.ref)).toEqual([ + { type: "waypoint", index: 0 }, + { type: "waypoint", index: 1 }, + { type: "waypoint", index: 2 }, + ]); + }); + + it("returns null for a tween with no positional keyframes", () => { + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }; + expect(buildMotionPathGeometry(read)).toBeNull(); + }); + + it("draws a single-axis (x-only) tween, defaulting the missing axis to 0", () => { + // Regression: an `x`-only tween (e.g. `to({ x: -260 })`) carries no `y`, so the + // builder used to skip every node → no path until the user added the 2nd axis. + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: -260 } }, + ], + }; + const geo = buildMotionPathGeometry(read); + expect(geo).not.toBeNull(); + expect(geo!.points).toBe("0,0 -260,0"); // y defaults to 0 → horizontal path + }); + + it("draws a y-only tween too (x defaults to 0)", () => { + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { y: 0 } }, + { percentage: 100, properties: { y: 500 } }, + ], + }; + expect(buildMotionPathGeometry(read)!.points).toBe("0,0 0,500"); + }); + + it("excludes keyframes missing a coordinate without throwing", () => { + const read: ReadTween = { + keyframes: [kf(0, 10, 20), { percentage: 50, properties: { x: 100 } }, kf(100, 200, 80)], + }; + const geo = buildMotionPathGeometry(read); + expect(geo!.nodes).toHaveLength(2); + expect(geo!.points).toBe("10,20 200,80"); + }); + + it("returns null when fewer than two valid nodes remain", () => { + const read: ReadTween = { keyframes: [kf(0, 10, 20)] }; + expect(buildMotionPathGeometry(read)).toBeNull(); + }); + + it("returns null for null input", () => { + expect(buildMotionPathGeometry(null)).toBeNull(); + }); +}); + +describe("nearestPointOnPath", () => { + const nodes = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]; + + it("projects onto the nearest segment and reports its index + fraction", () => { + const p = nearestPointOnPath(50, 20, nodes); + expect(p).toEqual({ x: 50, y: 0, segIndex: 0, t: 0.5, dist: 20 }); + }); + + it("reports t at the segment endpoints (0 at start, clamps to 1 past the end)", () => { + expect(nearestPointOnPath(0, 5, nodes)).toMatchObject({ segIndex: 0, t: 0 }); + expect(nearestPointOnPath(110, 0, nodes)).toMatchObject({ segIndex: 0, t: 1 }); + }); + + it("picks the second segment when closer to it", () => { + const p = nearestPointOnPath(120, 50, nodes); + expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 }); + }); + + it("clamps to an endpoint when the projection falls past the segment", () => { + const p = nearestPointOnPath(-40, -10, nodes); + expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 }); + }); + + it("returns null for fewer than two nodes", () => { + expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/motionPathGeometry.ts b/packages/studio/src/components/editor/motionPathGeometry.ts new file mode 100644 index 000000000..d2bf38c06 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathGeometry.ts @@ -0,0 +1,104 @@ +/** + * Convert a live tween (from `readRuntimeKeyframes`) into renderable motion-path + * geometry for the on-canvas overlay. Pure — no React/DOM — so it unit-tests in + * isolation. Coordinates are in composition space (the same space the overlay's + * viewBox uses), so the caller renders nodes/points directly. + */ +import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes"; + +/** Which source edit a dragged node maps to. */ +export type MotionNodeRef = + | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative % + | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index + +export interface MotionPathNode { + x: number; + y: number; + ref: MotionNodeRef; +} + +export interface MotionPathGeometry { + /** "linear" = x/y keyframes; "arc" = motionPath tween. */ + kind: "linear" | "arc"; + /** SVG polyline points: "x,y x,y ...". */ + points: string; + nodes: MotionPathNode[]; +} + +/** + * Build motion-path geometry, or null when the tween carries no positional path + * (fewer than two keyframes with both x and y). For motionPath tweens the + * keyframes are the arc waypoints (anchors), index-aligned with the source path + * — so a waypoint node at index `i` rewrites source waypoint `i`. + * + * ponytail: the arc is drawn as a polyline through its waypoints (matching the + * angular dotted look of the reference), not GSAP's resolved curve. Dense + * curve sampling is a later refinement if the straight-segment preview proves + * insufficient. + */ +/** + * Nearest point on a polyline to (px, py), with the index of the segment it + * lies on and `t` = how far along that segment the point sits (0 at `segIndex`, + * 1 at `segIndex + 1`). Used to position the ghost "add" node and decide where a + * new node goes: a motionPath waypoint inserts between `segIndex`/`segIndex + 1`, + * a keyframe interpolates its tween-% from the two adjacent keyframes via `t`. + * Coordinates are whatever space the caller passes (overlay uses absolute px). + */ +export function nearestPointOnPath( + px: number, + py: number, + nodes: Array<{ x: number; y: number }>, +): { x: number; y: number; segIndex: number; t: number; dist: number } | null { + if (nodes.length < 2) return null; + let best: { x: number; y: number; segIndex: number; t: number; dist: number } | null = null; + for (let i = 0; i < nodes.length - 1; i++) { + const a = nodes[i]!; + const b = nodes[i + 1]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2)); + const cx = a.x + t * dx; + const cy = a.y + t * dy; + const dist = Math.hypot(px - cx, py - cy); + if (!best || dist < best.dist) best = { x: cx, y: cy, segIndex: i, t, dist }; + } + return best; +} + +export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null { + if (!read) return null; + const isArc = Boolean(read.arcPath); + const nodes: MotionPathNode[] = []; + + // Index by source position so a waypoint node maps to the matching source + // anchor. Arc waypoints always carry x/y (never filtered), so source index + // and node order stay aligned. + // Which axes does the tween animate at all? A single-axis tween (e.g. + // `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP + // transform identity), so we default it and still draw a path. But if the tween + // DOES animate an axis and a given keyframe omits it, that value is interpolated + // (not 0) and can't be placed here → skip that node (the prior behavior). + const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v); + const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x)); + const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y)); + if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only) + + read.keyframes.forEach((kf, i) => { + if (tweenHasX && !finite(kf.properties.x)) return; + if (tweenHasY && !finite(kf.properties.y)) return; + nodes.push({ + x: tweenHasX ? (kf.properties.x as number) : 0, + y: tweenHasY ? (kf.properties.y as number) : 0, + ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage }, + }); + }); + + if (nodes.length < 2) return null; + + return { + kind: isArc ? "arc" : "linear", + points: nodes.map((n) => `${n.x},${n.y}`).join(" "), + nodes, + }; +} diff --git a/packages/studio/src/components/editor/motionPathSelection.ts b/packages/studio/src/components/editor/motionPathSelection.ts new file mode 100644 index 000000000..333290be4 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathSelection.ts @@ -0,0 +1,33 @@ +/** + * Resolving the selected element and the animation whose path is editable. + * Shared by the overlay and its diagnostics (kept here to avoid a circular + * import between the two). + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; + +export function selectorFor(sel: DomEditSelection | null): string | null { + if (!sel) return null; + if (sel.id) return `#${CSS.escape(sel.id)}`; + return sel.selector ?? null; +} + +/** The animation whose path is editable on-canvas: literal, statically resolved, + * and matching the rendered geometry kind. Returns null when the path can only + * be displayed (dynamic/helper tweens) — those nodes stay read-only. */ +export function editableAnimationId( + animations: GsapAnimation[], + kind: "linear" | "arc", +): string | null { + const ok = (a: GsapAnimation) => + !a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance; + if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null; + const a = animations.find( + (anim) => + anim.keyframes && + ok(anim) && + (anim.propertyGroup === "position" || + anim.keyframes.keyframes.some((k) => "x" in k.properties || "y" in k.properties)), + ); + return a?.id ?? null; +}