mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
- 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
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
|
||
|
||
export interface CubePose {
|
||
rotationX: number;
|
||
rotationY: number;
|
||
rotationZ: number;
|
||
}
|
||
|
||
const VIEW_W = 132;
|
||
const VIEW_H = 112;
|
||
const CX = VIEW_W / 2;
|
||
const CY = 54;
|
||
const RADIUS = 26;
|
||
// The cube mirrors the element's orientation 1:1 — no decorative viewing camera,
|
||
// so at rotation 0/0/0 it faces front (flat) exactly like the un-rotated element.
|
||
// The X/Y/Z axis gizmo keeps the flat-at-rest state readable.
|
||
const SENSITIVITY = 0.6; // degrees per pixel of drag
|
||
|
||
/**
|
||
* Draggable 3D-orientation cube. Drag to tilt (X/Y); Shift-drag to roll (Z).
|
||
* Presentational only: emits a live draft pose while dragging and a final pose
|
||
* on release — the parent owns live-previewing and committing to GSAP props.
|
||
*/
|
||
// transformPerspective (px) drives the cube's weak-perspective projection;
|
||
// 0 = off → flattest (largest projection distance).
|
||
const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
|
||
|
||
export function Transform3DCube({
|
||
pose,
|
||
perspective = 0,
|
||
defaultPerspective = 0,
|
||
z = 0,
|
||
onPoseDraft,
|
||
onPoseCommit,
|
||
onDepthDraft,
|
||
onDepthCommit,
|
||
onRecenter,
|
||
onKeyframe,
|
||
keyframed,
|
||
}: {
|
||
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). */
|
||
onPoseDraft?: (pose: CubePose) => void;
|
||
/** Fires once on pointer release with the final pose (commit). */
|
||
onPoseCommit: (pose: CubePose) => void;
|
||
/** Live depth (translateZ px) during a scroll; parent live-previews it. */
|
||
onDepthDraft?: (z: number) => void;
|
||
/** Committed depth (translateZ px) once a scroll burst settles. */
|
||
onDepthCommit?: (z: number) => void;
|
||
/** Reset to identity orientation. */
|
||
onRecenter?: () => void;
|
||
/** Toggle keyframing the 3D transform (convert the static set → keyframes). */
|
||
onKeyframe?: () => void;
|
||
/** Whether the 3D transform is already keyframed (drives the toggle's state). */
|
||
keyframed?: boolean;
|
||
}) {
|
||
const [draft, setDraft] = useState<CubePose | null>(null);
|
||
const [depthDraft, setDepthDraft] = useState<number | null>(null);
|
||
const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
|
||
const shown = draft ?? pose;
|
||
const shownZ = depthDraft ?? z;
|
||
|
||
// Scroll over the cube to push the element along Z (depth) — matches the
|
||
// 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);
|
||
// 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;
|
||
let pending: number | null = null;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
const onWheel = (e: WheelEvent) => {
|
||
const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current;
|
||
if (!commit) return;
|
||
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.
|
||
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);
|
||
timer = setTimeout(() => {
|
||
if (pending != null) commit(pending);
|
||
pending = null;
|
||
setDepthDraft(null); // fall back to the committed z prop
|
||
}, 160);
|
||
};
|
||
el.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => {
|
||
el.removeEventListener("wheel", onWheel);
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}, []);
|
||
|
||
// 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. 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(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
|
||
// unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the
|
||
// cube's orientation a true mirror of the element.
|
||
const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
||
const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
||
|
||
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
dragRef.current = { x: e.clientX, y: e.clientY, pose: shown };
|
||
setDraft(shown);
|
||
};
|
||
|
||
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||
const d = dragRef.current;
|
||
if (!d) return;
|
||
const dx = e.clientX - d.x;
|
||
const dy = e.clientY - d.y;
|
||
// dy→rotationX and shift dx→rotationZ are negated to match the projection's
|
||
// sign flip (above), so the cube's response to a drag is unchanged while the
|
||
// element now rotates in lock-step with it.
|
||
const next: CubePose = e.shiftKey
|
||
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) }
|
||
: {
|
||
rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY),
|
||
rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
|
||
rotationZ: d.pose.rotationZ,
|
||
};
|
||
setDraft(next);
|
||
onPoseDraft?.(next);
|
||
};
|
||
|
||
const onPointerUp = () => {
|
||
if (!dragRef.current) return;
|
||
dragRef.current = null;
|
||
if (draft) onPoseCommit(draft);
|
||
setDraft(null);
|
||
};
|
||
|
||
return (
|
||
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||
className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||
style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
role="slider"
|
||
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
|
||
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
||
shown.rotationY,
|
||
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
||
>
|
||
<defs>
|
||
<radialGradient id="cube3d-bg" cx="50%" cy="40%" r="65%">
|
||
<stop offset="0%" stopColor="#172220" />
|
||
<stop offset="100%" stopColor="#070a09" />
|
||
</radialGradient>
|
||
{/* Soft halo so the cube floats; SourceGraphic stays crisp on top. */}
|
||
<filter id="cube3d-glow" x="-40%" y="-40%" width="180%" height="180%">
|
||
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
||
<feMerge>
|
||
<feMergeNode in="blur" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
</defs>
|
||
<rect x="0" y="0" width={VIEW_W} height={VIEW_H} fill="url(#cube3d-bg)" />
|
||
{/* Grounding shadow under the cube. */}
|
||
<ellipse
|
||
cx={CX}
|
||
cy={CY + RADIUS + 22}
|
||
rx={RADIUS * 1.2}
|
||
ry={6.5}
|
||
fill="#000"
|
||
opacity={0.4}
|
||
/>
|
||
{/* Away-facing axes are drawn behind the cube, dimmed. */}
|
||
{axes
|
||
.filter((a) => !a.front)
|
||
.map((a) => (
|
||
<line
|
||
key={a.id}
|
||
x1={CX}
|
||
y1={CY}
|
||
x2={a.x2}
|
||
y2={a.y2}
|
||
stroke={a.color}
|
||
strokeWidth={1.4}
|
||
strokeLinecap="round"
|
||
opacity={0.3}
|
||
/>
|
||
))}
|
||
<g filter="url(#cube3d-glow)">
|
||
{faces.map((f) => (
|
||
<polygon
|
||
key={f.id}
|
||
points={f.points}
|
||
// Muted teal face, lit by direction; edges are a soft mint that
|
||
// brightens with how front-facing the face is, so corners read as
|
||
// crisp bevels instead of flat neon outlines.
|
||
fill={`hsl(166 44% ${Math.round(17 + f.shade * 37)}%)`}
|
||
stroke={`hsl(164 72% ${Math.round(56 + f.shade * 22)}%)`}
|
||
strokeWidth={1.1}
|
||
strokeOpacity={0.82}
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
paintOrder="stroke"
|
||
/>
|
||
))}
|
||
</g>
|
||
{/* Toward-facing axes on top, with a tip dot + X/Y/Z label. */}
|
||
{axes
|
||
.filter((a) => a.front)
|
||
.map((a) => (
|
||
<g key={a.id}>
|
||
<line
|
||
x1={CX}
|
||
y1={CY}
|
||
x2={a.x2}
|
||
y2={a.y2}
|
||
stroke={a.color}
|
||
strokeWidth={1.6}
|
||
strokeLinecap="round"
|
||
opacity={0.95}
|
||
/>
|
||
<circle cx={a.x2} cy={a.y2} r={2.4} fill={a.color} />
|
||
<text
|
||
x={a.x2 + (a.x2 - CX) * 0.12}
|
||
y={a.y2 + (a.y2 - CY) * 0.12 + 2}
|
||
fill={a.color}
|
||
fontSize={7}
|
||
fontWeight={700}
|
||
textAnchor="middle"
|
||
>
|
||
{a.id.toUpperCase()}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
{onRecenter && (
|
||
<button
|
||
type="button"
|
||
onClick={onRecenter}
|
||
title="Reset 3D orientation"
|
||
aria-label="Reset 3D orientation"
|
||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||
<circle cx="12" cy="12" r="9" strokeWidth="2" />
|
||
<path d="M12 3v18M3 12h18" strokeWidth="1.5" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
{onKeyframe && (
|
||
<button
|
||
type="button"
|
||
onClick={onKeyframe}
|
||
title={
|
||
keyframed
|
||
? "3D transform is keyframed — click a field diamond to add keyframes"
|
||
: "Keyframe the 3D transform (animate it over time)"
|
||
}
|
||
aria-label="Keyframe 3D transform"
|
||
aria-pressed={keyframed}
|
||
className={`absolute left-1.5 top-1.5 rounded p-0.5 hover:bg-neutral-800 ${
|
||
keyframed ? "text-[#5ff0bf]" : "text-neutral-500 hover:text-neutral-200"
|
||
}`}
|
||
>
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 12 12"
|
||
fill={keyframed ? "currentColor" : "none"}
|
||
stroke="currentColor"
|
||
>
|
||
<path d="M6 1.5L10.5 6 6 10.5 1.5 6z" strokeWidth="1.4" strokeLinejoin="round" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|