fix(studio): keyframe commit routing for 3D and cross-group edits (#1762)

- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a
  position tween — a fresh same-group tween with a 0% baseline is created instead
- editing at a playhead past the tween extends it and keyframes there (matches drag)
- update-keyframe MERGES into the existing keyframe instead of overwriting, so
  editing one property no longer drops z/transformPerspective (the lens then
  animated from 0 and the element popped)
- dragging a keyframed element with a constant position tween keyframes rather
  than writing a static set
This commit is contained in:
Miguel Ángel
2026-06-27 11:32:43 -04:00
committed by GitHub
parent 6a729b7e03
commit 2d3b19d255
13 changed files with 467 additions and 125 deletions
@@ -21,6 +21,7 @@ import {
elementHome,
hasMotionPathPlugin,
isPreviewHtmlElement,
transformWDivisor,
useMotionPathData,
} from "./useMotionPathData";
@@ -39,6 +40,7 @@ type DragState = {
initX: number;
initY: number;
scale: number;
pScale: number;
ref: MotionNodeRef;
};
@@ -71,7 +73,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
handleGsapRemoveKeyframe,
handleGsapDeleteAllForElement,
} = useDomEditContext();
const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData(
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
iframeRef,
selectorFor(selection),
);
@@ -156,8 +158,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
e.preventDefault();
const sc = r.width / compW;
const elHome = elementHome(live);
const px = Math.round((e.clientX - r.left) / sc - elHome.x);
const py = Math.round((e.clientY - r.top) / sc - elHome.y);
// De-magnify: the click lands on the projected (1/m44-magnified) path, so
// divide the home-relative offset by the perspective factor to recover the
// stored composition offset (inverse of the `* pScale` applied at draw).
const ps = 1 / transformWDivisor(live);
const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps);
const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps);
const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
void commitCreatePath(createSelector, t, px, py, commitMutation);
setMotionPathArmed(false);
@@ -232,7 +238,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
: geometry.nodes;
// ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
// stay offsets so the drag commit writes the right tween values.
const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
// Magnify the animated offsets by the element's perspective factor (1/m44, via
// pScale) so the path tracks the *projected* element. `home` is the projection
// pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
// elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
// pointer position is mapped back to a stored offset (create + node drag).
const abs = nodes.map((n) => ({
...n,
ax: home.x + n.x * pScale,
ay: home.y + n.y * pScale,
}));
const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
// Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
// rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
@@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
initX: x,
initY: y,
scale,
pScale,
ref,
};
setDraft({ index, x, y });
@@ -273,8 +289,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
if (!d) return;
setDraft({
index: d.index,
x: d.initX + (e.clientX - d.startX) / d.scale,
y: d.initY + (e.clientY - d.startY) / d.scale,
x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale,
y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale,
});
};
// fallow-ignore-next-line complexity
@@ -286,8 +302,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
if (!animId) return;
const screenDx = e.clientX - d.startX;
const screenDy = e.clientY - d.startY;
const x = Math.round(d.initX + screenDx / d.scale);
const y = Math.round(d.initY + screenDy / d.scale);
const x = Math.round(d.initX + screenDx / d.scale / d.pScale);
const y = Math.round(d.initY + screenDy / d.scale / d.pScale);
// Click-vs-drag is decided in SCREEN space, not composition px: the old guard
// compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
// multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would
@@ -148,6 +148,19 @@ export const PropertyPanel = memo(function PropertyPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
[gsapRuntimeValues, gsapAnimations, element, currentTime],
);
// The 3D Transform panel should be reachable on ANY element, not only ones GSAP is
// already animating — otherwise you can't add depth/rotation to a fresh static
// element (the panel never appears, the classic chicken-and-egg). Default to
// identity when there are no runtime values yet; the first edit creates the
// gsap.set via commitStaticSet, after which real runtime values flow in.
const gsap3dValues: Record<string, number> = gsapRuntimeValues ?? {
rotationX: 0,
rotationY: 0,
rotationZ: 0,
z: 0,
scale: 1,
transformPerspective: 0,
};
if (!element) {
return (
@@ -490,33 +503,31 @@ export const PropertyPanel = memo(function PropertyPanel({
)}
</div>
</div>
{gsapRuntimeValues && (
<PropertyPanel3dTransform
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={animIdForProp}
gsapKeyframes={navKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={(el, props) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
| null
| undefined;
const sel = el.id ? `#${el.id}` : el.selector;
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
if (win?.gsap && node) win.gsap.set(node, props);
}}
/>
)}
<PropertyPanel3dTransform
gsapRuntimeValues={gsap3dValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={animIdForProp}
gsapKeyframes={navKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={(el, props) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
| null
| undefined;
const sel = el.id ? `#${el.id}` : el.selector;
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
if (win?.gsap && node) win.gsap.set(node, props);
}}
/>
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
Stacking
@@ -29,6 +29,7 @@ const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px /
export function Transform3DCube({
pose,
perspective = 0,
defaultPerspective = 0,
z = 0,
onPoseDraft,
onPoseCommit,
@@ -41,6 +42,8 @@ export function Transform3DCube({
pose: CubePose;
/** Element's transformPerspective (px); drives the cube's foreshortening. */
perspective?: number;
/** Comp-derived lens used for depth feedback before a perspective is committed. */
defaultPerspective?: number;
/** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
z?: number;
/** Fires on every drag move with the in-progress pose (parent live-previews). */
@@ -68,8 +71,12 @@ export function Transform3DCube({
// studio's "scroll = z depth" gesture-recording convention. A non-passive
// listener is required so preventDefault can stop the panel from scrolling.
const svgRef = useRef<SVGSVGElement | null>(null);
const depthRef = useRef({ z, onDepthDraft, onDepthCommit });
depthRef.current = { z, onDepthDraft, onDepthCommit };
// Perspective lens (committed, else the comp-derived default the panel will
// apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth
// can't cross the lens. Defined here so the wheel handler can read it via the ref.
const lens = perspective > 0 ? perspective : defaultPerspective;
const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens });
depthRef.current = { z, onDepthDraft, onDepthCommit, lens };
useEffect(() => {
const el = svgRef.current;
if (!el) return;
@@ -81,7 +88,14 @@ export function Transform3DCube({
e.preventDefault();
// ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
// it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
pending = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
// Clamp depth in front of the perspective lens. At z ≥ lens the element sits
// at/behind the virtual camera and the projection lens/(lensz) blows up or
// inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short
// of the lens; allow pushing well back (smaller) but not absurdly far.
const L = depthRef.current.lens;
if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4));
pending = next;
draft?.(pending);
setDepthDraft(pending); // live-scale the cube while scrolling
if (timer) clearTimeout(timer);
@@ -100,15 +114,15 @@ export function Transform3DCube({
// Depth feedback: the cube scales like the element would — translateZ(z) under
// a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
// farther (z<0) smaller. Fall back to the default lens so depth always reads in
// the gizmo even before a perspective is set.
const lens = perspective > 0 ? perspective : 800;
const depthScale = Math.max(0.4, Math.min(2.2, lens / (lens - shownZ)));
// farther (z<0) smaller. Use the committed perspective, else the comp-derived
// lens the panel is about to apply — same value in both, so the cube doesn't
// jump when the commit lands. If neither is known, skip the scale (no lens).
const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1;
const projOpts = {
cx: CX,
cy: CY,
r: RADIUS * depthScale,
persp: pxToProjPersp(perspective),
persp: pxToProjPersp(lens),
};
// The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
// and RotateZ act in planes that contain Y, so they read inverted in the gizmo
@@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
};
}
/**
* The perspective w-divisor (matrix3d m44) of the element's current transform.
* For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
* element renders 1/m44× larger and a translate of `d` composition px moves
* `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used
* to keep the drag offset → screen-movement mapping correct for depth elements,
* which the flat-scale fast path below would otherwise get wrong by 1/m44.
*/
function readTransformWDivisor(element: HTMLElement): number {
const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
if (!t || !t.startsWith("matrix3d(")) return 1;
const parts = t.slice("matrix3d(".length, -1).split(",");
const w = Number.parseFloat(parts[15] ?? "");
return Number.isFinite(w) && w > 0 ? w : 1;
}
export function measureManualOffsetDragScreenToOffsetMatrix(
element: HTMLElement,
initialOffset: { x: number; y: number },
@@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
) {
const sx = options.scaleX || 1;
const sy = options.scaleY || 1;
return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } };
// Fold in the perspective foreshortening: a depth element (z≠0) moves
// 1/m44× faster on screen than its flat scale implies, so the screen→offset
// matrix must scale by m44 or the element outruns the pointer/overlay.
const w = readTransformWDivisor(element);
return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } };
}
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
@@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: {
// drag is acceptable — the final committed position is always exact.
const scaleX = input.rect.editScaleX || 1;
const scaleY = input.rect.editScaleY || 1;
const w = readTransformWDivisor(input.element);
return {
ok: true,
member: {
@@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: {
baseGsap,
initialPathOffset,
gestureToken,
screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
originRect: input.rect,
},
};
@@ -6,9 +6,19 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { Transform3DCube, type CubePose } from "./Transform3DCube";
// Default perspective (px) applied when depth is first set, so translateZ is
// visible. ~800px is a moderate lens — closer = stronger foreshortening.
const DEFAULT_DEPTH_PERSPECTIVE = 800;
// translateZ only foreshortens under a perspective lens. Rather than hardcode one
// (an arbitrary px value reads wrong at different canvas sizes), derive it from the
// element's composition: perspective = composition height puts the virtual camera
// one comp-height back, a natural ~53° vertical FOV that looks the same whether the
// canvas is 720p or 4K. Falls back to the element's own height only if the comp size
// can't be read (detached/unmeasured), never to a fixed magic number.
function naturalDepthPerspective(el: HTMLElement | null | undefined): number {
if (!el) return 0;
const root = el.closest("[data-hf-inner-root],[data-composition-id]") as HTMLElement | null;
const compHeight = root?.offsetHeight || el.ownerDocument?.documentElement?.clientHeight || 0;
if (compHeight > 0) return Math.round(compHeight);
return Math.round((el.offsetHeight || 0) * 4) || 0;
}
type KeyframeEntry = Array<{
percentage: number;
@@ -42,17 +52,10 @@ interface PropertyPanel3dTransformProps {
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
}
type CommitAnimatedProperty = (
element: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
/** The draggable cube + its commit/recenter/live-preview wiring. */
function Cube3dControl({
element,
gsapRuntimeValues,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onLivePreviewProps,
onKeyframe,
@@ -60,8 +63,7 @@ function Cube3dControl({
}: {
element: DomEditSelection;
gsapRuntimeValues: Record<string, number>;
onCommitAnimatedProperty: CommitAnimatedProperty;
onCommitAnimatedProperties?: (
onCommitAnimatedProperties: (
element: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
@@ -74,6 +76,15 @@ function Cube3dControl({
rotationY: gsapRuntimeValues.rotationY ?? 0,
rotationZ: gsapRuntimeValues.rotationZ ?? 0,
};
// Comp-derived lens (see naturalDepthPerspective) applied the first time depth is
// set, so the scene's foreshortening scales with the canvas instead of a magic 800.
const depthPerspective = naturalDepthPerspective(element.element);
// A gentle, fixed "depth pose" tilt (degrees) dropped on a flat element the first
// time it gets depth, so translateZ reads as 3D foreshortening instead of a plain
// resize — small enough to look like a premium card, not a flip.
const DEPTH_POSE_X = 10;
const DEPTH_POSE_Y = -15;
const isFlat = Math.round(pose.rotationX) === 0 && Math.round(pose.rotationY) === 0;
// Commit only the rotation axes the drag actually changed (each rounded to a
// whole degree). Reuses the keyframe-aware animated-property commit, so a drag
// at the playhead writes/updates a keyframe just like the numeric fields.
@@ -86,13 +97,8 @@ function Cube3dControl({
const axes = Object.keys(changedProps);
if (axes.length === 0) return;
// ONE keyframe for the whole pose change — avoids per-axis commits racing into
// adjacent duplicate keyframes. Fall back to per-axis if no batched commit.
if (onCommitAnimatedProperties) {
void onCommitAnimatedProperties(element, changedProps);
} else {
for (const [axis, v] of Object.entries(changedProps))
onCommitAnimatedProperty(element, axis, v);
}
// adjacent duplicate keyframes.
void onCommitAnimatedProperties(element, changedProps);
};
const recenter = () => {
// ONE commit for the whole reset — six per-axis commits meant six soft-reloads
@@ -105,15 +111,10 @@ function Cube3dControl({
scale: 1,
transformPerspective: 0,
};
if (onCommitAnimatedProperties) {
void onCommitAnimatedProperties(element, identity);
} else {
for (const [prop, v] of Object.entries(identity))
void onCommitAnimatedProperty(element, prop, v);
}
void onCommitAnimatedProperties(element, identity);
};
// Immediate element feedback while dragging — set the live transform without a
// source write; the release commits via onCommitAnimatedProperty.
// source write; the release commits via commitPose.
const livePreview = (next: CubePose) =>
onLivePreviewProps?.(element, {
rotationX: next.rotationX,
@@ -127,29 +128,47 @@ function Cube3dControl({
<Transform3DCube
pose={pose}
perspective={gsapRuntimeValues.transformPerspective ?? 0}
defaultPerspective={depthPerspective}
z={gsapRuntimeValues.z ?? 0}
onPoseDraft={livePreview}
onPoseCommit={commitPose}
onDepthDraft={(z) =>
onLivePreviewProps?.(
element,
gsapRuntimeValues.transformPerspective
? { z }
: { z, transformPerspective: DEFAULT_DEPTH_PERSPECTIVE },
)
}
onDepthCommit={(z) => {
// translateZ is invisible without a perspective lens — apply a sensible
// default the first time depth is set so scrolling visibly moves the
// element. The user can still fine-tune via the Perspective field.
if (!gsapRuntimeValues.transformPerspective) {
void onCommitAnimatedProperty(
element,
"transformPerspective",
DEFAULT_DEPTH_PERSPECTIVE,
);
onDepthDraft={(z) => {
// Preview WITH a lens so depth is visible while scrolling — the same
// default the commit applies, so the element doesn't snap on release.
const preview: Record<string, number> = gsapRuntimeValues.transformPerspective
? { z }
: { z, transformPerspective: depthPerspective };
// Depth-pose preview: a flat element only scales under Z, so mirror the
// commit and preview the gentle tilt that makes the depth read as 3D.
if (isFlat) {
preview.rotationX = DEPTH_POSE_X;
preview.rotationY = DEPTH_POSE_Y;
}
void onCommitAnimatedProperty(element, "z", z);
onLivePreviewProps?.(element, preview);
}}
onDepthCommit={(z) => {
// Best-UX depth: scroll moves Z, and a 3D transform always has a lens —
// like an After Effects camera. translateZ is invisible without a
// perspective, so the FIRST time depth is added (Perspective still 0) we
// set a sensible comp-derived lens ONCE. Every later scroll touches Z
// only, and Perspective stays an independent, editable field. The cube's
// scroll is clamped in front of the lens, so Z can't run away past it.
const props: Record<string, number> = { z };
if (!gsapRuntimeValues.transformPerspective && depthPerspective > 0) {
props.transformPerspective = depthPerspective;
}
// Depth-pose: a flat element (no tilt) only scales under Z — it can't read
// as depth. So the first time depth lands on a flat element, also drop a
// gentle fixed tilt; the foreshortening makes depth read as 3D IN PLACE
// (no screen travel, per-element lens unchanged). Once the element has any
// tilt, depth scrolls touch Z only. Reset tilt to 0 to go flat again.
if (isFlat) {
props.rotationX = DEPTH_POSE_X;
props.rotationY = DEPTH_POSE_Y;
}
// One commit for all props so the writes can't race read-modify-write on
// the same script (which dropped a prop and reverted after a seek).
void onCommitAnimatedProperties(element, props);
}}
onRecenter={recenter}
onKeyframe={onKeyframe}
@@ -308,11 +327,10 @@ export function PropertyPanel3dTransform({
</button>
{collapsed ? null : (
<>
{onCommitAnimatedProperty && (
{onCommitAnimatedProperties && (
<Cube3dControl
element={element}
gsapRuntimeValues={gsapRuntimeValues}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onLivePreviewProps={onLivePreviewProps}
keyframed={(gsapKeyframes ?? []).some(
@@ -5,6 +5,38 @@ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGe
type Rect = { left: number; top: number; width: number; height: number };
// The translate (e/f) components of an element's computed transform, in comp px.
// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top.
function transformTranslate(el: HTMLElement): { x: number; y: number } {
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
if (!t || t === "none") return { x: 0, y: 0 };
const m3 = t.match(/matrix3d\(([^)]+)\)/);
if (m3) {
const v = m3[1].split(",").map(Number);
return { x: v[12] || 0, y: v[13] || 0 };
}
const m = t.match(/matrix\(([^)]+)\)/);
if (m) {
const v = m[1].split(",").map(Number);
return { x: v[4] || 0, y: v[5] || 0 };
}
return { x: 0, y: 0 };
}
// Perspective foreshortening of the element's OWN transform (matrix3d m44). A
// depth element (translateZ toward the viewer) renders 1/m44× larger, so its
// animated x/y offsets travel 1/m44× further on screen than the flat preview
// scale implies. Returns 1 for 2D transforms. The motion path magnifies its
// offset points by 1/m44 (and de-magnifies pointer→offset) so the drawn path and
// its draggable nodes track the projected element instead of drifting off it.
export function transformWDivisor(el: HTMLElement): number {
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
if (!t || !t.startsWith("matrix3d(")) return 1;
const v = t.slice("matrix3d(".length, -1).split(",");
const w = Number.parseFloat(v[15] ?? "");
return Number.isFinite(w) && w > 0 ? w : 1;
}
export function elementHome(el: HTMLElement): { x: number; y: number } {
let left = 0;
let top = 0;
@@ -12,6 +44,14 @@ export function elementHome(el: HTMLElement): { x: number; y: number } {
while (node) {
left += node.offsetLeft;
top += node.offsetTop;
// Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the
// element actually renders, so the path must anchor on top of them. The element's
// OWN transform is excluded — that's the animated offset the path itself draws.
if (node !== el) {
const t = transformTranslate(node);
left += t.x;
top += t.y;
}
const parent = node.offsetParent as HTMLElement | null;
if (!parent || parent.hasAttribute("data-composition-id")) break;
node = parent;
@@ -62,6 +102,7 @@ export function useMotionPathData(
geometryResolved: boolean;
visibleInPreview: boolean;
home: { x: number; y: number } | null;
pScale: number;
} {
const [rect, setRect] = useState<Rect | null>(null);
const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
@@ -69,6 +110,9 @@ export function useMotionPathData(
const geometryResolved = resolvedForRef.current === selector;
const [visibleInPreview, setVisibleInPreview] = useState(true);
const [home, setHome] = useState<{ x: number; y: number } | null>(null);
// Perspective magnification (1/m44) of the selected element — applied to the
// path's offset points so depth (translateZ) elements' paths track on screen.
const [pScale, setPScale] = useState(1);
useEffect(() => {
if (!selector) {
@@ -105,6 +149,8 @@ export function useMotionPathData(
setHome((prev) =>
prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
);
const ps = 1 / transformWDivisor(live);
setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
}
}
raf = requestAnimationFrame(tick);
@@ -132,5 +178,5 @@ export function useMotionPathData(
return () => window.clearInterval(id);
}, [selector, iframeRef]);
return { rect, geometry, geometryResolved, visibleInPreview, home };
return { rect, geometry, geometryResolved, visibleInPreview, home, pScale };
}