Skip to content
Merged
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
17 changes: 17 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ export function initSandboxRuntimeModular(): void {
swallow("runtime.init.site1", err);
}
}
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
// that per-frame warning destabilizes the preview and makes the selection
// overlay stop tracking the pointer. Idempotent + best-effort.
const ensureAutoMarkerNoop = (): void => {
const g = window.gsap as { registerPlugin?: (plugin: unknown) => void } | undefined;
const w = window as Window & { __hfAutoNoopRegistered?: boolean };
if (!g?.registerPlugin || w.__hfAutoNoopRegistered) return;
try {
g.registerPlugin({ name: "_auto", init: () => false });
w.__hfAutoNoopRegistered = true;
} catch {
// a stray warning is preferable to a broken runtime
}
};
ensureAutoMarkerNoop();
// Normalize html/body so browser defaults (8px margin, white background) never
// bleed into renders as white bars. Runs in both preview and render contexts,
// eliminating the preview/render parity gap that existed when only the React
Expand Down
17 changes: 17 additions & 0 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2348,6 +2348,23 @@ export function updateKeyframeInScript(
// `properties` would wipe the primitive — leave the keyframe untouched.
return script;
}
// MERGE edited props into the existing keyframe, preserving props not in this edit
// (z, transformPerspective, rotation, …). A whole-value rebuild drops them, so editing
// one prop at the 0% keyframe strips z/transformPerspective and the element pops.
// Mirrors acorn updateKeyframeInScript; parity-locked by gsapWriterParity.corpus.
const existing = match.prop.value;
if (existing?.type === "ObjectExpression") {
const props = (existing.properties ?? []) as AstNode[];
const upsert = (key: string, valueCode: string) => {
const idx = props.findIndex((p: AstNode) => isObjectProperty(p) && propKeyName(p) === key);
const node = parseExpr(`({ ${safeKey(key)}: ${valueCode} })`).properties[0];
if (idx >= 0) props[idx] = node;
else props.push(node);
};
for (const [k, v] of Object.entries(properties)) upsert(k, valueToCode(v));
if (ease !== undefined) upsert("ease", JSON.stringify(ease));
return recast.print(loc.parsed.ast).code;
}
match.prop.value = buildKeyframeValueNode(properties, ease);
return recast.print(loc.parsed.ast).code;
}
Expand Down
18 changes: 15 additions & 3 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,10 +780,22 @@ export function updateKeyframeInScript(
const match = findKfPropByPct(kfPropNode.value, percentage);
if (!match) return script;

const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
const ms = new MagicString(script);
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
// MERGE the edited props into the existing keyframe, preserving properties already
// keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value
// overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0%
// keyframe would strip z / transformPerspective, so the lens then animates from 0 and
// the element pops. Mirrors addKeyframeToScript's merge-into-existing branch.
if (match.prop.value?.type === "ObjectExpression") {
for (const [k, v] of Object.entries(properties)) {
upsertProp(ms, match.prop.value, k, v);
}
if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease);
} else {
const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
}
return ms.toString();
}

Expand Down
32 changes: 24 additions & 8 deletions packages/studio/src/components/editor/MotionPathOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
elementHome,
hasMotionPathPlugin,
isPreviewHtmlElement,
transformWDivisor,
useMotionPathData,
} from "./useMotionPathData";

Expand All @@ -39,6 +40,7 @@ type DragState = {
initX: number;
initY: number;
scale: number;
pScale: number;
ref: MotionNodeRef;
};

Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
initX: x,
initY: y,
scale,
pScale,
ref,
};
setDraft({ index, x, y });
Expand All @@ -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
Expand All @@ -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
Expand Down
65 changes: 38 additions & 27 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions packages/studio/src/components/editor/Transform3DCube.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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). */
Expand Down Expand Up @@ -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;
Expand All @@ -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/(lens−z) 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);
Expand All @@ -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
Expand Down
25 changes: 23 additions & 2 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
},
};
Expand Down
Loading
Loading