import { useState } from "react"; import type { DomEditSelection } from "./domEditingTypes"; import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; import { MetricField } from "./propertyPanelPrimitives"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { Transform3DCube, type CubePose } from "./Transform3DCube"; // 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; properties: Record; ease?: string; }> | null; interface PropertyPanel3dTransformProps { gsapRuntimeValues: Record; gsapAnimId: string | null; resolveAnimIdForProp?: (prop: string) => string | null; gsapKeyframes: KeyframeEntry; currentPct: number; elStart: number; elDuration: number; element: DomEditSelection; onCommitAnimatedProperty?: ( element: DomEditSelection, property: string, value: number, ) => Promise; /** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */ onCommitAnimatedProperties?: ( element: DomEditSelection, props: Record, ) => Promise; onSeekToTime?: (time: number) => void; onRemoveKeyframe?: (animId: string, pct: number) => void; onConvertToKeyframes?: (animId: string, duration?: number) => void; /** Live-set props on the preview element during a cube drag (no source write). */ onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; } /** The draggable cube + its commit/recenter/live-preview wiring. */ function Cube3dControl({ element, gsapRuntimeValues, onCommitAnimatedProperties, onLivePreviewProps, onKeyframe, keyframed, }: { element: DomEditSelection; gsapRuntimeValues: Record; onCommitAnimatedProperties: ( element: DomEditSelection, props: Record, ) => Promise; onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; onKeyframe?: () => void; keyframed?: boolean; }) { const pose: CubePose = { rotationX: gsapRuntimeValues.rotationX ?? 0, 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. const commitPose = (next: CubePose) => { const changedProps: Record = {}; for (const axis of ["rotationX", "rotationY", "rotationZ"] as const) { const rounded = Math.round(next[axis]); if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded; } 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. void onCommitAnimatedProperties(element, changedProps); }; const recenter = () => { // ONE commit for the whole reset — six per-axis commits meant six soft-reloads // (six flashes) for a single click. Batch like commitPose does. const identity = { rotationX: 0, rotationY: 0, rotationZ: 0, z: 0, scale: 1, transformPerspective: 0, }; void onCommitAnimatedProperties(element, identity); }; // Immediate element feedback while dragging — set the live transform without a // source write; the release commits via commitPose. const livePreview = (next: CubePose) => onLivePreviewProps?.(element, { rotationX: next.rotationX, rotationY: next.rotationY, rotationZ: next.rotationZ, }); return (
{ // 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 = 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; } 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 = { 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} keyframed={keyframed} />

Drag to tilt · Shift-drag to roll · Scroll for depth

); } interface FieldCtx { element: DomEditSelection; gsapRuntimeValues: Record; gsapKeyframes: KeyframeEntry; gsapAnimId: string | null; currentPct: number; elStart: number; elDuration: number; resolveAnimIdForProp?: (prop: string) => string | null; onCommitAnimatedProperty?: ( element: DomEditSelection, property: string, value: number, ) => Promise; onSeekToTime?: (time: number) => void; onRemoveKeyframe?: (animId: string, pct: number) => void; onConvertToKeyframes?: (animId: string, duration?: number) => void; } const parseDeg = (s: string): number | null => { const n = Number.parseFloat(s.replace("°", "")); return Number.isFinite(n) ? n : null; }; const parseScale = (s: string): number | null => { const n = Number.parseFloat(s); return Number.isFinite(n) ? n : null; }; const parsePxNonNeg = (s: string): number | null => { const v = parsePxMetricValue(s); return v != null && v >= 0 ? v : null; }; /** * One 3D-transform field: a number/scrub input plus its keyframe diamond, so * rotation / perspective / Z / scale can each be keyframed just like Layout's * X / Y — the diamond was previously missing on the rotation + perspective rows. */ function Transform3dField({ label, prop, scrub, format, parse, defaultValue, ctx, }: { label: string; prop: string; scrub?: boolean; format: (v: number) => string; parse: (s: string) => number | null; defaultValue: number; ctx: FieldCtx; }) { const { gsapAnimId, onCommitAnimatedProperty } = ctx; const idFor = (p: string) => ctx.resolveAnimIdForProp?.(p) ?? gsapAnimId; const current = ctx.gsapRuntimeValues[prop] ?? defaultValue; return (
{ const v = parse(next); if (v != null && onCommitAnimatedProperty) { void onCommitAnimatedProperty(ctx.element, prop, v); } }} />
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( ctx.onSeekToTime?.(ctx.elStart + (pct / 100) * ctx.elDuration)} onAddKeyframe={() => { if (onCommitAnimatedProperty) void onCommitAnimatedProperty(ctx.element, prop, current); }} onRemoveKeyframe={(pct) => { const id = idFor(prop); if (id) ctx.onRemoveKeyframe?.(id, pct); }} onConvertToKeyframes={() => { const id = idFor(prop); // Pass the element's clip duration so a converted static 3D `set` // spans the whole clip (keyframes land in range at any playhead). if (id) ctx.onConvertToKeyframes?.(id, ctx.elDuration); }} /> )}
); } export function PropertyPanel3dTransform({ gsapRuntimeValues, gsapAnimId, resolveAnimIdForProp, gsapKeyframes, currentPct, elStart, elDuration, element, onCommitAnimatedProperty, onCommitAnimatedProperties, onSeekToTime, onRemoveKeyframe, onConvertToKeyframes, onLivePreviewProps, }: PropertyPanel3dTransformProps) { // Expanded by default — the cube gizmo is the headline of this panel, so show // it up front rather than hiding it behind a collapsed header. const [collapsed, setCollapsed] = useState(false); const ctx: FieldCtx = { element, gsapRuntimeValues, gsapKeyframes, gsapAnimId, currentPct, elStart, elDuration, resolveAnimIdForProp, onCommitAnimatedProperty, onSeekToTime, onRemoveKeyframe, onConvertToKeyframes, }; return (
{collapsed ? null : ( <> {onCommitAnimatedProperties && ( "rotationX" in kf.properties || "rotationY" in kf.properties || "rotationZ" in kf.properties, )} onKeyframe={() => { // Convert the 3D ("other"-group) static set to keyframes so the // cube can animate; spans the element's clip via elDuration. const id = resolveAnimIdForProp?.("rotationX") ?? gsapAnimId; if (id) onConvertToKeyframes?.(id, elDuration); }} /> )}
String(v)} parse={parseScale} defaultValue={1} /> `${v}°`} parse={parseDeg} defaultValue={0} /> `${v}°`} parse={parseDeg} defaultValue={0} /> `${v}°`} parse={parseDeg} defaultValue={0} />
)}
); }