Files
hyperframes/packages/studio/src/components/editor/Transform3DCube.tsx
T
Miguel Ángel 2d3b19d255 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
2026-06-27 11:32:43 -04:00

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/(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);
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>
);
}