mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): draggable 3D-transform cube in the design panel (#1710)
* feat(studio): draggable 3D-transform cube in the design panel
Add a Figma-style draggable cube to the 3D Transform section so users can set an
element's 3D orientation by dragging instead of typing degrees. Drag tilts the
element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets
the 3D transform to identity. The cube previews the orientation live and commits
on release.
It's an input affordance over the existing keyframe-aware commit path
(commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just
like the numeric fields, no new mutation infra.
- transform3dProjection.ts: pure unit-cube projection with back-face culling and
painter ordering (no 3D dependency), unit-tested.
- Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit).
- Surface the two missing numeric fields (RotZ, Perspective). Perspective drives
the new editable `transformPerspective` prop (per-element depth) rather than
CSS `perspective` (which only affects children).
* feat(studio): polish 3D cube — collapsed by default, compact lit cube, live drag preview
Address review of the first cut:
- 3D Transform section is now collapsible and collapsed by default (it was tall
and ate panel space).
- Redesign the cube: compact and centered (was full-width), resting isometric
camera so it reads as a 3D cube at identity instead of a flat square,
directional per-face lighting, gradient backdrop + grounding shadow.
- Live element preview while dragging: onLivePreviewProps gsap.sets the live
transform on the preview element so it moves WITH the cube; release still
commits via the keyframe-aware path.
- Extract Cube3dControl to keep the panel component under the complexity gate.
* fix(studio): persist static 3D transform + refine cube edges
The cube (and the RotX/RotY numeric fields) didn't stick on an element whose
only tween is a position 'set' — commitAnimatedProperty tried to convert the
zero-duration hold into keyframes, so the rotation was never written and the
cube snapped back. Handle the static-set case: merge the property into the set
(update-property) so a static 3D rotation/perspective persists, and the cube
reads it back from runtime.
Also refine the cube rendering: muted teal lit faces with edges that brighten
with how front-facing each face is (crisp bevels, not flat neon outlines), a
soft halo glow, and a stronger grounding shadow.
* feat(studio): 3D transform — keyframe diamonds, flash-free commits, in-cube perspective
- Keyframe diamonds: RotX/RotY/RotZ + Perspective (and Z/Scale) now each carry a
KeyframeNavigation diamond, so 3D transforms can be keyframed like Layout X/Y.
Refactored the six fields onto a shared Transform3dField.
- Flash-free: static-set 3D commits now use instantPatch (in-place runtime patch,
no soft reload), and the set fast-path was widened to the 3D channels
(rotationX/Y/Z, z, transformPerspective) — dragging the cube / scrubbing a 3D
field no longer flashes.
- In-cube perspective: a Persp slider lives in the cube widget and the cube's
foreshortening reflects transformPerspective live.
* feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs
- Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the
cube center — away-facing axes dimmed behind the cube, toward-facing on top
with a tip dot + label — so orientation is readable at a glance.
- Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or
via window.__hfDebug). Instruments the commit path (which branch + picked
tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's
instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce
with the console open to pinpoint any remaining flash to a specific commit.
* fix(studio): make the 3D cube mirror the element's orientation 1:1
The resting isometric camera made the cube always look tilted, so at rotation
0/0/0 the cube showed a 3D pose while the element was flat — the cube didn't
represent the element. Drop the decorative camera (VIEW_RX/RY = 0): the cube now
faces front at identity, exactly matching the un-rotated element, and tilts to
match as the element rotates. The X/Y/Z axis gizmo keeps the flat-at-rest state
readable.
Flash status (from the gated [hf-3d:*] logs): every commit now reports
'instant (no flash)' via instantPatch — the soft-reload flashes are resolved.
* fix(studio): stop design-panel flicker — read transform channels live
Each 3D commit bumps the gsap cache; the panel then re-read runtime values, but
readGsapRuntimeValuesForPanel only included props already present in the parsed
gsapAnimations. A just-set rotationX isn't in the parse yet, so for that window
the cube + fields dropped it and flickered to 0. Always read the core transform
channels (x/y/rotation/rotationX/Y/Z/z/scale/transformPerspective/opacity)
directly via gsap.getProperty — which reflects the in-place instant patch — so
the panel shows the true current value with no flicker.
* refactor(studio): extract collectPanelPropKeys to keep panel reader under complexity gate
* feat(studio): keyframable 3D transforms — convert a static set to keyframes
The cube/3D fields stored rotation as a static 'set', and convert-to-keyframes
flatly refused to convert a set (gsapParser.ts) — so two 3D 'keyframes' just
overwrote the same static value with no interpolation.
Now a set converts to an animatable to(): resolveConversionProps emits both
endpoints from the set's value (visual unchanged until edited), and both writers
flip set→to, drop the immediateRender hold, and add a duration. The element's
clip duration is threaded through the convert chain (3D field → handler →
convertToKeyframes → route → parser) so the keyframes span the whole clip and
land in range at any playhead. Click a 3D field's diamond to convert, then edit
at different playheads to animate. Acorn writer mirrored; recast round-trip test
added.
* feat(studio): keyframe toggle on the 3D cube
The cube had no keyframe affordance, so dragging it only ever wrote the static
set (logs showed every rotation commit as path:static-set) and nothing
interpolated — converting required clicking a numeric field's diamond, which
isn't discoverable while driving the cube.
Add a keyframe diamond button to the cube widget: it converts the 3D
('other'-group) static set to keyframes spanning the element's clip, and lights
up when the transform is already keyframed. Once keyframed, cube drags + numeric
edits add keyframes at the playhead and the 3D rotation interpolates.
* feat(studio): auto-keyframe 3D transforms on animated elements + stop AssetsTab 404 loop
3D transforms now auto-keyframe like drag/resize/rotate: when the element is
already animated (its clip has keyframes), editing a 3D prop converts the static
set to keyframes so edits at other playheads interpolate — no manual keyframe
toggle needed. Purely static elements still write a static set (and the cube's
keyframe button remains a manual opt-in for them).
Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array
reference (new each render) so it re-fetched the (usually missing) manifest on
every re-render — spamming 404s and churning the left sidebar during cube drags.
Key on a stable join and cache the 404 so a missing manifest is fetched once.
* fix(studio): cube writes one keyframe per drag (no duplicate keyframes)
The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first
axis's auto-keyframe convert shifted the tween so the second axis computed a
slightly different percentage → two adjacent keyframes instead of one.
Add a batched commitAnimatedProperties that writes all changed props into ONE
keyframe, and route the cube through it (commitAnimatedProperty is now a thin
single-prop wrapper). Threaded through the panel chain; numeric fields keep the
single-prop path. Set-path and keyframe-path extracted to helpers to stay under
the complexity gate.
* refactor(studio): extract AudioRow from AssetsTab to satisfy file-size check
The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line
cap (702). Move the self-contained AudioRow sub-component to its own file,
bringing AssetsTab to 493 lines.
* fix(studio): self-heal stale animationId on 3D property commit
A 3D property edit (cube drag / field) picks its target from the panel's
selectedGsapAnimations cache. When keyframes were just removed or the script
changed underneath, that id is gone server-side and the commit POST 404s
('animation not found'). The raw commitMutation already toasts but rethrows,
so the rejection escaped as an uncaught promise. Catch it in
commitAnimatedProperties and bump the cache so the panel re-syncs and the
next edit self-heals.
* fix(studio): batch the 3D reset into one commit (was six flashes)
Reset 3D orientation looped six props (rotationX/Y/Z, z, scale,
transformPerspective) through the single-property commit, so one click
triggered six separate soft-reloads — six preview flashes. Batch them into
one onCommitAnimatedProperties call (one keyframe, one reload), matching the
cube-drag path.
* fix(studio): 3D-edit a static element writes a set, not keyframes
Editing the 3D transform of an element with no keyframes created a keyframed
tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes).
A static element should stay static — same as manual drag / resize / rotate,
which tl.set() it. Route no-keyframe elements to a set: update an existing one
in place, or create a dedicated tl.set carrying all axes in ONE add mutation.
The single mutation also avoids the per-axis id race (a flat tween's
group-derived id shifts after the first prop, 404-ing the next and polluting
an unrelated tween).
* feat(studio): instant 3D keyframe edits via in-place tween rebuild
Dragging the cube on an animated element soft-reloaded the iframe on every
edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into
sub-tweens at creation and ignores later vars.keyframes mutations, so the value
can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill
it and recreate it on the same parent timeline at the same position with the
edited keyframe merged and all other vars preserved, then re-seek — no iframe
reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a
rotation edit lands on the rotation tween, never a co-located position tween.
Declines (→ soft reload) for array-form, motionPath, or dynamic values.
* feat(studio): static 3D transform persists as off-timeline gsap.set (no 0% keyframe)
Adjusting a 3D transform on an element with no keyframes created a
tl.set(...,0), which the timeline renders as a 0% keyframe diamond — even
though it's a static hold, not animated. Persist a newly-created static 3D
hold as a base gsap.set(...) instead: it runs immediately, sits OFF the
timeline, and shows no keyframe marker (matching the manual-drag UX).
- Model: GsapAnimation.global marks a base gsap.set vs an on-timeline tl.set.
- Parser (recast + acorn): parse a STRING-LITERAL gsap.set("#sel", {...}) as an
editable global set so it round-trips and re-edits in place; variable-target
gsap.set(el, ...) holds stay opaque surrounding source (unchanged).
- Serializer + writers: emit gsap.set(sel, props) (no timeline var, no position)
when global; in-place updates keep it a gsap.set.
- add mutation gains global; commitStaticSet sends it when creating a holder.
* fix(studio): static manual drag persists as off-timeline gsap.set, instant (no flash/diamond)
After keyframes are removed, manually dragging a now-static element wrote a
tl.set(...,0) — an on-timeline hold that shows a 0% keyframe diamond and
soft-reloaded on the first nudge (a flash/teleport between the overlay and the
committed position). Make the static position/rotation drag persist as a base
gsap.set (off-timeline, no marker), like the 3D path.
A gsap.set has no runtime tween to patch, so add a 'global-set' instant-patch
that applies the value straight to the element (gsap.set(el, props)) — the
element is static on these channels, so it reflects instantly with no soft
reload. Existing tl.set holds keep the tween 'set' patch; only global sets use
global-set. Create now carries the instant patch too, so the first nudge is
flash-free.
* fix(studio): a base gsap.set shows no keyframe diamond (timeline + panel)
A base gsap.set is parsed as an editable set (for idempotent re-edits), but
synthesizeFlatTweenKeyframes turned it into a synthetic 0% keyframe, so the
timeline track and the panel field showed a phantom keyframe diamond for a
static, non-animated value. Return null for a global set so it contributes no
keyframes — it's an off-timeline static hold, not a keyframe.
* fix(studio): a static set never shows a keyframe diamond (timeline + panel)
A set (gsap.set OR tl.set) is a static hold — a value applied at one point,
not an animated keyframe — so it must not synthesize a phantom keyframe. The
prior fix only skipped GLOBAL gsap.set; on-timeline tl.set holds (and ones a
split/conversion produced) still showed a diamond. Skip every set, which also
aligns the AST keyframe cache with the runtime scan (it already drops every
zero-duration set).
* fix(studio): batch set-property edits (reset 3D no longer 404s)
Reset 3D fires 6 props (rotationX/Y/Z, z, scale, perspective) at a set;
commitSetProps updated them one at a time. A set's id is GROUP-derived, so the
moment scale lands on a rotation set its id shifts (-other -> mixed), 404-ing
the next prop (perspective never got set). Add an update-properties mutation
(merge many props in one call) and have commitSetProps/commitStaticSet use it —
one round-trip, no mid-loop id shift.
* style(studio): fix format + trim 3D-patch helper complexity
oxfmt the runtime-patch file (the failing Format/Preflight check) and reduce
the complexity of the new helpers: flatten keyframeVarsCarryChannel with .some,
extract finiteNumericProps from applyGlobalSet, suppress the inherently-defensive
rebuildKeyframeTween guard chain.
* chore(studio): remove [hf-3d:*] debug logs (3D transform verified working)
Strip the log3d call sites + the debug3d util now that the 3D transform /
static-set / keyframe-rebuild paths are confirmed working.
* chore(studio): strategic [hf-pos:*] logs for position-commit path audit
Temporary DEV-gated logs to confirm which path each drag takes: single drag →
GSAP code path (single-gsap), multi-select/group drag → DEPRECATED CSS-var path
(group-css, applyStudioPathOffset → --hf-studio-offset), and the single CSS
fallback (single-css). To be removed once group drag is routed through GSAP.
* fix(studio): route multi-select group drag through GSAP code path
Group drag committed positions via the deprecated --hf-studio-offset CSS
var (applyStudioPathOffset) and outright blocked GSAP-animated elements.
Single drag already routes through tryGsapDragIntercept (tl.set /
keyframes / gsap.set); group drag now does the same per element, so a
multi-select move writes real GSAP code with no CSS-var fallback. Removed
the now-dead CSS group commit.
* feat(studio): live candidate highlight while marquee-selecting
The marquee only revealed what it selected on mouse-up, so it was easy to
grab too much or too little. Now each element the marquee box currently
intersects is outlined live (studio-accent) as you drag, before release —
so you can see the selection forming. Shares one synchronous OBB/SAT
intersection pass between the live highlight and the commit; the async
source-probe still runs only once, on mouse-up.
* chore(studio): remove temporary [hf-pos:*] position-path debug logs
Investigation done — group drag now routes through the GSAP code path, so
the CSS-vs-GSAP path-audit scaffolding (logPos / debugPos) is no longer
needed. Removes the util and its imports/calls.
* fix(studio): marquee selects/highlights elements at their real positions
The marquee derived element boxes from elementObbCorners, whose
non-identity-transform branch reconstructed the box from offsetLeft/offsetTop
plus the element's own transform matrix — ignoring the matrix translate
(m.e/m.f) and any ancestor transforms. Mid-GSAP-animation (elements carry a
translate() transform), that put boxes at their pre-translate layout
position, so the marquee highlighted/selected the wrong elements vs. the
box shown when you click an element directly.
Route the marquee through the same toOverlayRect basis the selection and
group boxes use (a getBoundingClientRect-based AABB). Now highlight ==
selection-commit == the click-selection box, at the element's real on-screen
position. Drops the buggy OBB/SAT path (elementObbCorners,
marqueeIntersectsObb); AABB matches the selection box, which never rotated.
Adds dev-only [hf-marquee:*] tracing (per-element rect + intersect + skip
reason, JSON) to debug what the marquee sees; stripped from prod builds.
* fix(studio): off-canvas elements no longer render a selection-style border
OffCanvasIndicators drew two layers per partly-off-screen element: a dashed
sliver on the protruding part, plus a solid studio-accent border (with the
selection box-shadow) over the on-canvas portion. That solid border only
ever draws for UNSELECTED elements (selected ones get a real selection box
via the filter), so an unselected off-canvas element looked selected.
Removed the solid inside layer — the dashed protruding sliver stays as the
off-canvas hint.
* chore(studio): remove [hf-marquee:*] debug logs
Marquee position fix is verified; strip the dev-only tracing scaffolding
(logMarquee/debugLabel/debug param) back to the lean intersection loop.
* fix(studio): convert a global gsap.set to a seekable timeline tween + review cleanups
Primary fix: converting a global `gsap.set` to keyframes flipped only the
method (set->to), leaving the callee object `gsap` — emitting `gsap.to(...)`,
an off-timeline tween that fires once at load and isn't on the paused master
`window.__timelines` (the engine can't seek/render it). Reachable from the
cube's keyframe toggle + maybeAutoKeyframeSet on the global sets commitStaticSet
creates. Now re-roots onto the timeline var and adds the position arg, in both
the recast and acorn writers; covered by a convert test seeded from gsap.set in
each path.
Review cleanups: drop dead confirmDelete/<DeleteConfirm> in AudioRow; drop the
always-zero viewRx/viewRy camera params from the 3D projection; un-export four
internal-only symbols (clears fallow unused-exports); re-add the collectMarqueeHits
complexity suppression dropped with the debug scaffolding.
* chore(studio): green the CI gate + 3D panel expanded by default
- File-size: extract the marquee/candidate render into MarqueeOverlay so
DomEditOverlay drops back under the 600-line cap.
- Fallow complexity: suppress the 8 accepted-complexity findings from the 3D/
runtime work (resolveRuntimeTween, readRuntimeKeyframes, hasNonHoldTweenForElement,
commitKeyframeProps, scored, ImageCard, selectionShapeStyles, off-canvas effect)
with the bare directive the linter recognizes.
- 3D transform panel now defaults to expanded (the cube gizmo is the headline).
This commit is contained in:
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
import { MarqueeOverlay } from "./MarqueeOverlay";
|
||||
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { collectDomEditLayerItems } from "./domEditingLayers";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
@@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
||||
onMarqueeSelectRef.current = onMarqueeSelect;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const selectionShapeStyles = (() => {
|
||||
const fallback = {
|
||||
borderRadius: 8 as string | number,
|
||||
@@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
// outside the composition bounds so users can find them.
|
||||
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
||||
// fallow-ignore-next-line complexity
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
@@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
activeCompositionPathRef={activeCompositionPathRef}
|
||||
onSelectionChangeRef={onSelectionChangeRef}
|
||||
/>
|
||||
{marquee.marqueeRect && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
|
||||
style={{
|
||||
left: marquee.marqueeRect.left,
|
||||
top: marquee.marqueeRect.top,
|
||||
width: marquee.marqueeRect.width,
|
||||
height: marquee.marqueeRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
|
||||
<GridOverlay
|
||||
visible={gridVisible}
|
||||
spacing={gridSpacing}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Rect } from "../../utils/marqueeGeometry";
|
||||
|
||||
interface MarqueeOverlayProps {
|
||||
/** Elements the marquee currently intersects — outlined live before mouse-up. */
|
||||
candidateRects: Rect[];
|
||||
/** The marquee drag rectangle itself, or null when not marquee-selecting. */
|
||||
marqueeRect: Rect | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The marquee selection visuals: a live "candidate" outline on each element the
|
||||
* box currently touches, plus the dashed drag rectangle. Extracted from
|
||||
* DomEditOverlay to keep that file under the 600-line cap.
|
||||
*/
|
||||
export function MarqueeOverlay({ candidateRects, marqueeRect }: MarqueeOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{candidateRects.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute rounded-sm border border-studio-accent bg-studio-accent/5"
|
||||
style={{ left: r.left, top: r.top, width: r.width, height: r.height }}
|
||||
/>
|
||||
))}
|
||||
{marqueeRect && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.width,
|
||||
height: marqueeRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ interface OffCanvasIndicatorsProps {
|
||||
/**
|
||||
* Dashed teal indicators for elements whose bounds extend past the composition
|
||||
* (the "gray zone"). The in-canvas portion is clipped away so only the
|
||||
* protruding sliver is dashed; the inside portion gets a solid outline.
|
||||
* protruding sliver is dashed — the on-canvas part gets no outline, since a
|
||||
* solid selection-style border on an unselected element reads as "selected".
|
||||
* Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
|
||||
*/
|
||||
export function OffCanvasIndicators({
|
||||
@@ -57,7 +58,6 @@ export function OffCanvasIndicators({
|
||||
const clipOutside = hasInside
|
||||
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
|
||||
: undefined;
|
||||
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
|
||||
const selectOffCanvas = async () => {
|
||||
const el = elements.current.get(r.key);
|
||||
if (!el) return;
|
||||
@@ -98,11 +98,6 @@ export function OffCanvasIndicators({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={{ clipPath: clipInside }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -90,6 +90,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
@@ -523,9 +524,20 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { 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) is inversely related to effect strength, with 0 = off.
|
||||
// Map a 0..1 slider strength to px and to the cube's weak-perspective projection.
|
||||
const STRONG_PX = 200;
|
||||
const WEAK_PX = 1600;
|
||||
const PX_RANGE = WEAK_PX - STRONG_PX;
|
||||
const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE));
|
||||
const pxToStrength = (px: number) =>
|
||||
px <= 0
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE));
|
||||
const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
|
||||
|
||||
/** Horizontal "perspective strength" slider — left = none, right = dramatic. */
|
||||
function PerspectiveSlider({
|
||||
value,
|
||||
onDraft,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onDraft?: (px: number) => void;
|
||||
onCommit: (px: number) => void;
|
||||
}) {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const strength = pxToStrength(value);
|
||||
const fromEvent = (clientX: number) => {
|
||||
const r = trackRef.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return 0;
|
||||
return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width)));
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 pb-1.5 pt-1">
|
||||
<span className="text-[8px] font-medium uppercase tracking-wide text-neutral-600">Persp</span>
|
||||
<div
|
||||
ref={trackRef}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
onDraft?.(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (draggingRef.current) onDraft?.(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
onCommit(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
draggingRef.current = false;
|
||||
}}
|
||||
className="relative h-3 flex-1 cursor-ew-resize touch-none"
|
||||
>
|
||||
<div className="absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-neutral-700" />
|
||||
<div
|
||||
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-[#5ff0bf]"
|
||||
style={{ width: `${strength * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-900 bg-[#5ff0bf]"
|
||||
style={{ left: `${strength * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Transform3DCube({
|
||||
pose,
|
||||
perspective = 0,
|
||||
onPoseDraft,
|
||||
onPoseCommit,
|
||||
onPerspectiveDraft,
|
||||
onPerspectiveCommit,
|
||||
onRecenter,
|
||||
onKeyframe,
|
||||
keyframed,
|
||||
}: {
|
||||
pose: CubePose;
|
||||
/** Element's transformPerspective (px); drives the cube's foreshortening. */
|
||||
perspective?: 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 + committed perspective (px) from the in-cube slider. */
|
||||
onPerspectiveDraft?: (px: number) => void;
|
||||
onPerspectiveCommit?: (px: 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 dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
|
||||
const shown = draft ?? pose;
|
||||
const projOpts = {
|
||||
cx: CX,
|
||||
cy: CY,
|
||||
r: RADIUS,
|
||||
persp: pxToProjPersp(perspective),
|
||||
};
|
||||
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;
|
||||
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
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
{onPerspectiveCommit && (
|
||||
<PerspectiveSlider
|
||||
value={perspective}
|
||||
onDraft={onPerspectiveDraft}
|
||||
onCommit={onPerspectiveCommit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const PROP_LABELS: Record<string, string> = {
|
||||
rotationY: "Rotate Y",
|
||||
rotationZ: "Rotate Z",
|
||||
perspective: "Perspective",
|
||||
transformPerspective: "Perspective",
|
||||
transformOrigin: "Transform Origin",
|
||||
opacity: "Opacity",
|
||||
scale: "Scale",
|
||||
@@ -57,6 +58,7 @@ export const PROP_UNITS: Record<string, string> = {
|
||||
rotationY: "°",
|
||||
rotationZ: "°",
|
||||
perspective: "px",
|
||||
transformPerspective: "px",
|
||||
transformOrigin: "",
|
||||
opacity: "%",
|
||||
scale: "×",
|
||||
@@ -80,6 +82,8 @@ export const PROP_TOOLTIPS: Record<string, string> = {
|
||||
rotationZ: "Rotate around the screen-facing Z axis",
|
||||
perspective:
|
||||
"3D depth context for child elements; set it on a parent when rotating children in 3D",
|
||||
transformPerspective:
|
||||
"3D depth for THIS element's own X/Y rotation — lower = stronger perspective (try 600–1000)",
|
||||
transformOrigin: "Pivot point for transforms, for example center center or 50% 50%",
|
||||
width: "Element width",
|
||||
height: "Element height",
|
||||
@@ -150,6 +154,7 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
|
||||
rotationY: { step: 1 },
|
||||
rotationZ: { step: 1 },
|
||||
perspective: { min: 0, step: 1 },
|
||||
transformPerspective: { min: 0, step: 1 },
|
||||
skewX: { min: -90, max: 90, step: 1 },
|
||||
skewY: { min: -90, max: 90, step: 1 },
|
||||
width: { min: 0, step: 1 },
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { DomEditSelection } from "./domEditing";
|
||||
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
||||
import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
|
||||
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
||||
import { toOverlayRect } from "./domEditOverlayGeometry";
|
||||
|
||||
interface MarqueeState {
|
||||
startX: number;
|
||||
@@ -16,13 +17,26 @@ interface MarqueeState {
|
||||
|
||||
const MARQUEE_THRESHOLD_PX = 4;
|
||||
|
||||
interface MarqueeHit {
|
||||
element: HTMLElement;
|
||||
rect: Rect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous core of the marquee: the elements whose overlay-space rect
|
||||
* intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
|
||||
* single-selection / group-selection boxes, so what the marquee highlights
|
||||
* and selects is exactly the box the user sees when they click an element.
|
||||
* Shared by the live candidate highlight (per pointer-move) and the mouse-up
|
||||
* commit. No async source probe — that only happens once, on commit.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runMarqueeIntersection(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
function collectMarqueeHits(
|
||||
rect: Rect,
|
||||
iframe: HTMLIFrameElement,
|
||||
overlayEl: HTMLDivElement,
|
||||
activeCompositionPath: string,
|
||||
): Promise<DomEditSelection[]> {
|
||||
): MarqueeHit[] {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return [];
|
||||
|
||||
@@ -38,22 +52,42 @@ async function runMarqueeIntersection(
|
||||
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
|
||||
};
|
||||
|
||||
const hits: DomEditSelection[] = [];
|
||||
const hits: MarqueeHit[] = [];
|
||||
for (const item of items) {
|
||||
const el = item.element;
|
||||
if (!isElementComputedVisible(el)) continue;
|
||||
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
|
||||
const corners = elementObbCorners(el, overlayEl, iframe);
|
||||
if (!corners) continue;
|
||||
if (!marqueeIntersectsObb(rect, corners)) continue;
|
||||
const sel = await resolveDomEditSelection(el, {
|
||||
const overlayRect = toOverlayRect(overlayEl, iframe, el);
|
||||
if (!overlayRect) continue;
|
||||
const r: Rect = {
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
};
|
||||
if (!rectsOverlap(rect, r)) continue;
|
||||
hits.push({ element: el, rect: r });
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
async function runMarqueeIntersection(
|
||||
rect: Rect,
|
||||
iframe: HTMLIFrameElement,
|
||||
overlayEl: HTMLDivElement,
|
||||
activeCompositionPath: string,
|
||||
): Promise<DomEditSelection[]> {
|
||||
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
||||
const hits: DomEditSelection[] = [];
|
||||
for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
|
||||
const sel = await resolveDomEditSelection(element, {
|
||||
activeCompositionPath,
|
||||
isMasterView,
|
||||
skipSourceProbe: true,
|
||||
});
|
||||
if (sel) hits.push(sel);
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
@@ -75,12 +109,12 @@ interface MarqueeGesturesDeps {
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
||||
const marqueeRef = useRef<MarqueeState | null>(null);
|
||||
const [marqueeRect, setMarqueeRect] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
|
||||
// Live "candidate" highlight: the elements the marquee currently touches,
|
||||
// shown before mouse-up so you can see what you're about to select. The
|
||||
// iframe DOM doesn't mutate during a drag, so a sync intersection per move
|
||||
// is cheap (clean layout → no thrash).
|
||||
const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
|
||||
|
||||
const commitMarquee = useCallback(
|
||||
async (
|
||||
@@ -111,17 +145,24 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
||||
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
|
||||
m.pastThreshold = true;
|
||||
}
|
||||
setMarqueeRect({
|
||||
const rect: Rect = {
|
||||
left: Math.min(m.startX, m.currentX),
|
||||
top: Math.min(m.startY, m.currentY),
|
||||
width: Math.abs(m.currentX - m.startX),
|
||||
height: Math.abs(m.currentY - m.startY),
|
||||
});
|
||||
};
|
||||
setMarqueeRect(rect);
|
||||
const iframe = deps.iframeRef.current;
|
||||
const overlay = deps.overlayRef.current;
|
||||
if (iframe && overlay) {
|
||||
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
||||
setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
|
||||
}
|
||||
return;
|
||||
}
|
||||
deps.gestures.onPointerMove(event);
|
||||
},
|
||||
[deps.gestures, deps.overlayRef],
|
||||
[deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
@@ -148,6 +189,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
||||
deps.onMarqueeSelectRef.current?.([], false);
|
||||
}
|
||||
setMarqueeRect(null);
|
||||
setCandidateRects([]);
|
||||
return;
|
||||
}
|
||||
deps.gestures.onPointerUp(event);
|
||||
@@ -159,10 +201,11 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
||||
if (marqueeRef.current) {
|
||||
marqueeRef.current = null;
|
||||
setMarqueeRect(null);
|
||||
setCandidateRects([]);
|
||||
return;
|
||||
}
|
||||
deps.gestures.clearPointerState(deps.selectionRef);
|
||||
}, [deps.gestures, deps.selectionRef]);
|
||||
|
||||
return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
|
||||
return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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";
|
||||
|
||||
type KeyframeEntry = Array<{
|
||||
percentage: number;
|
||||
@@ -24,9 +26,216 @@ interface PropertyPanel3dTransformProps {
|
||||
property: string,
|
||||
value: number,
|
||||
) => Promise<void>;
|
||||
/** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */
|
||||
onCommitAnimatedProperties?: (
|
||||
element: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
onRemoveKeyframe?: (animId: string, pct: number) => void;
|
||||
onConvertToKeyframes?: (animId: string) => 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<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,
|
||||
keyframed,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
gsapRuntimeValues: Record<string, number>;
|
||||
onCommitAnimatedProperty: CommitAnimatedProperty;
|
||||
onCommitAnimatedProperties?: (
|
||||
element: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
|
||||
onKeyframe?: () => void;
|
||||
keyframed?: boolean;
|
||||
}) {
|
||||
const pose: CubePose = {
|
||||
rotationX: gsapRuntimeValues.rotationX ?? 0,
|
||||
rotationY: gsapRuntimeValues.rotationY ?? 0,
|
||||
rotationZ: gsapRuntimeValues.rotationZ ?? 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<string, number> = {};
|
||||
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. 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);
|
||||
}
|
||||
};
|
||||
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,
|
||||
};
|
||||
if (onCommitAnimatedProperties) {
|
||||
void onCommitAnimatedProperties(element, identity);
|
||||
} else {
|
||||
for (const [prop, v] of Object.entries(identity))
|
||||
void onCommitAnimatedProperty(element, prop, v);
|
||||
}
|
||||
};
|
||||
// Immediate element feedback while dragging — set the live transform without a
|
||||
// source write; the release commits via onCommitAnimatedProperty.
|
||||
const livePreview = (next: CubePose) =>
|
||||
onLivePreviewProps?.(element, {
|
||||
rotationX: next.rotationX,
|
||||
rotationY: next.rotationY,
|
||||
rotationZ: next.rotationZ,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-2 px-2">
|
||||
<div className="mx-auto max-w-[184px]">
|
||||
<Transform3DCube
|
||||
pose={pose}
|
||||
perspective={gsapRuntimeValues.transformPerspective ?? 0}
|
||||
onPoseDraft={livePreview}
|
||||
onPoseCommit={commitPose}
|
||||
onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
|
||||
onPerspectiveCommit={(px) =>
|
||||
void onCommitAnimatedProperty(element, "transformPerspective", px)
|
||||
}
|
||||
onRecenter={recenter}
|
||||
onKeyframe={onKeyframe}
|
||||
keyframed={keyframed}
|
||||
/>
|
||||
<p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
|
||||
Drag to tilt · Shift-drag to roll
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldCtx {
|
||||
element: DomEditSelection;
|
||||
gsapRuntimeValues: Record<string, number>;
|
||||
gsapKeyframes: KeyframeEntry;
|
||||
gsapAnimId: string | null;
|
||||
currentPct: number;
|
||||
elStart: number;
|
||||
elDuration: number;
|
||||
resolveAnimIdForProp?: (prop: string) => string | null;
|
||||
onCommitAnimatedProperty?: (
|
||||
element: DomEditSelection,
|
||||
property: string,
|
||||
value: number,
|
||||
) => Promise<void>;
|
||||
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 (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label={label}
|
||||
value={format(current)}
|
||||
scrub={scrub}
|
||||
onCommit={(next) => {
|
||||
const v = parse(next);
|
||||
if (v != null && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(ctx.element, prop, v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
||||
<KeyframeNavigation
|
||||
property={prop}
|
||||
keyframes={ctx.gsapKeyframes}
|
||||
currentPercentage={ctx.currentPct}
|
||||
onSeek={(pct) => 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyPanel3dTransform({
|
||||
@@ -39,110 +248,120 @@ export function PropertyPanel3dTransform({
|
||||
elDuration,
|
||||
element,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onLivePreviewProps,
|
||||
}: PropertyPanel3dTransformProps) {
|
||||
const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
|
||||
// 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 (
|
||||
<div className="mt-3 border-t border-neutral-800/40 pt-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
3D Transform
|
||||
</div>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
|
||||
>
|
||||
<span>3D Transform</span>
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
|
||||
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
|
||||
</svg>
|
||||
</button>
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{onCommitAnimatedProperty && (
|
||||
<Cube3dControl
|
||||
element={element}
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
||||
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
||||
onLivePreviewProps={onLivePreviewProps}
|
||||
keyframed={(gsapKeyframes ?? []).some(
|
||||
(kf) =>
|
||||
"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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="Z"
|
||||
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
|
||||
prop="z"
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = parsePxMetricValue(next);
|
||||
if (v != null && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "z", v);
|
||||
}
|
||||
}}
|
||||
format={formatPxMetricValue}
|
||||
parse={parsePxMetricValue}
|
||||
defaultValue={0}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
||||
<KeyframeNavigation
|
||||
property="z"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={() => {
|
||||
if (onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
|
||||
}
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => {
|
||||
const id = idFor("z");
|
||||
if (id) onRemoveKeyframe?.(id, pct);
|
||||
}}
|
||||
onConvertToKeyframes={() => {
|
||||
const id = idFor("z");
|
||||
if (id) onConvertToKeyframes?.(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="Scale"
|
||||
value={String(gsapRuntimeValues.scale ?? 1)}
|
||||
prop="scale"
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next);
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "scale", v);
|
||||
}
|
||||
}}
|
||||
format={(v) => String(v)}
|
||||
parse={parseScale}
|
||||
defaultValue={1}
|
||||
/>
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="RotX"
|
||||
prop="rotationX"
|
||||
format={(v) => `${v}°`}
|
||||
parse={parseDeg}
|
||||
defaultValue={0}
|
||||
/>
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="RotY"
|
||||
prop="rotationY"
|
||||
format={(v) => `${v}°`}
|
||||
parse={parseDeg}
|
||||
defaultValue={0}
|
||||
/>
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="RotZ"
|
||||
prop="rotationZ"
|
||||
format={(v) => `${v}°`}
|
||||
parse={parseDeg}
|
||||
defaultValue={0}
|
||||
/>
|
||||
<Transform3dField
|
||||
ctx={ctx}
|
||||
label="Perspective"
|
||||
prop="transformPerspective"
|
||||
scrub
|
||||
format={formatPxMetricValue}
|
||||
parse={parsePxNonNeg}
|
||||
defaultValue={0}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
||||
<KeyframeNavigation
|
||||
property="scale"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={() => {
|
||||
if (onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
|
||||
}
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => {
|
||||
const id = idFor("scale");
|
||||
if (id) onRemoveKeyframe?.(id, pct);
|
||||
}}
|
||||
onConvertToKeyframes={() => {
|
||||
const id = idFor("scale");
|
||||
if (id) onConvertToKeyframes?.(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MetricField
|
||||
label="RotX"
|
||||
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next.replace("°", ""));
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "rotationX", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MetricField
|
||||
label="RotY"
|
||||
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next.replace("°", ""));
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "rotationY", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,12 +68,18 @@ export interface PropertyPanelProps {
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
/** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's
|
||||
* rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */
|
||||
onCommitAnimatedProperties?: (
|
||||
selection: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
@@ -212,10 +218,8 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
|
||||
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
||||
export const EMPTY_STYLES: Record<string, string> = {};
|
||||
|
||||
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
export const EMPTY_FILTER_VALUE = "none";
|
||||
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
export const BOX_SHADOW_PRESETS = {
|
||||
const EMPTY_FILTER_VALUE = "none";
|
||||
const BOX_SHADOW_PRESETS = {
|
||||
none: "none",
|
||||
soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
|
||||
lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
|
||||
@@ -275,13 +279,7 @@ export function parsePxMetricValue(value: string): number | null {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
export function clampPanelNumber(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number,
|
||||
): number {
|
||||
function clampPanelNumber(value: number, min: number, max: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -491,6 +489,38 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
|
||||
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
// Core transform channels the panel ALWAYS reads live — even before a just-set
|
||||
// value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the
|
||||
// cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty
|
||||
// reflects the in-place instant patch, so it's the true current value.
|
||||
const ALWAYS_READ_CHANNELS = [
|
||||
"x",
|
||||
"y",
|
||||
"rotation",
|
||||
"rotationX",
|
||||
"rotationY",
|
||||
"rotationZ",
|
||||
"z",
|
||||
"scale",
|
||||
"transformPerspective",
|
||||
"opacity",
|
||||
];
|
||||
|
||||
/** Every property key the panel should read for an element: animated props + the
|
||||
* always-read transform channels. */
|
||||
function collectPanelPropKeys(gsapAnimations: GsapAnimation[]): Set<string> {
|
||||
const keys = new Set<string>(ALWAYS_READ_CHANNELS);
|
||||
for (const anim of gsapAnimations) {
|
||||
if (anim.keyframes) {
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
for (const p of Object.keys(kf.properties)) keys.add(p);
|
||||
}
|
||||
}
|
||||
for (const p of Object.keys(anim.properties)) keys.add(p);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function readGsapRuntimeValuesForPanel(
|
||||
gsapAnimId: string | null,
|
||||
gsapAnimations: GsapAnimation[],
|
||||
@@ -511,15 +541,7 @@ export function readGsapRuntimeValuesForPanel(
|
||||
if (!gsap?.getProperty) return null;
|
||||
const el = iframe.contentDocument?.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const propKeys = new Set<string>();
|
||||
for (const anim of gsapAnimations) {
|
||||
if (anim.keyframes) {
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
for (const p of Object.keys(kf.properties)) propKeys.add(p);
|
||||
}
|
||||
}
|
||||
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
||||
}
|
||||
const propKeys = collectPanelPropKeys(gsapAnimations);
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of propKeys) {
|
||||
const v = Number(gsap.getProperty(el, prop));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { projectAxes, projectCubeFaces, rotate, wrapDeg } from "./transform3dProjection";
|
||||
|
||||
const OPTS = { cx: 50, cy: 50, r: 30, persp: 4 };
|
||||
|
||||
describe("projectCubeFaces", () => {
|
||||
it("shows the front face (and only front-facing faces) at identity", () => {
|
||||
const faces = projectCubeFaces(0, 0, 0, OPTS);
|
||||
const ids = faces.map((f) => f.id);
|
||||
expect(ids).toContain("front");
|
||||
// Head-on: side/top/bottom normals are edge-on (n.z ≈ 0) → culled.
|
||||
expect(ids).not.toContain("back");
|
||||
expect(faces.length).toBeGreaterThanOrEqual(1);
|
||||
expect(faces.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("reveals the top face when tilted forward on X", () => {
|
||||
const ids = projectCubeFaces(45, 0, 0, OPTS).map((f) => f.id);
|
||||
expect(ids).toContain("front");
|
||||
expect(ids).toContain("top");
|
||||
expect(ids).not.toContain("bottom");
|
||||
});
|
||||
|
||||
it("reveals a side face when rotated on Y (CSS rotateY convention)", () => {
|
||||
const ids = projectCubeFaces(0, 45, 0, OPTS).map((f) => f.id);
|
||||
expect(ids).toContain("front");
|
||||
expect(ids).toContain("left");
|
||||
expect(ids).not.toContain("right");
|
||||
});
|
||||
|
||||
it("never returns more than 3 faces (a cube shows at most 3 at once)", () => {
|
||||
for (const [rx, ry, rz] of [
|
||||
[30, 30, 0],
|
||||
[60, 60, 45],
|
||||
[135, 20, 90],
|
||||
]) {
|
||||
expect(projectCubeFaces(rx, ry, rz, OPTS).length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("rotationZ rolls the silhouette without changing which faces are visible", () => {
|
||||
const a = projectCubeFaces(0, 0, 0, OPTS)
|
||||
.map((f) => f.id)
|
||||
.sort();
|
||||
const b = projectCubeFaces(0, 0, 90, OPTS)
|
||||
.map((f) => f.id)
|
||||
.sort();
|
||||
expect(b).toEqual(a);
|
||||
// …but the projected coordinates differ (it rolled).
|
||||
expect(projectCubeFaces(0, 0, 90, OPTS)[0]?.points).not.toEqual(
|
||||
projectCubeFaces(0, 0, 0, OPTS)[0]?.points,
|
||||
);
|
||||
});
|
||||
|
||||
it("paints far faces before near faces (painter's order)", () => {
|
||||
const faces = projectCubeFaces(35, 35, 0, OPTS);
|
||||
for (let i = 1; i < faces.length; i++) {
|
||||
expect(faces[i]!.depth).toBeGreaterThanOrEqual(faces[i - 1]!.depth);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectAxes", () => {
|
||||
it("returns the three X/Y/Z axes with standard colors", () => {
|
||||
const axes = projectAxes(0, 0, 0, OPTS);
|
||||
expect(axes.map((a) => a.id).sort()).toEqual(["x", "y", "z"]);
|
||||
const z = axes.find((a) => a.id === "z")!;
|
||||
expect(z.color).toMatch(/#/);
|
||||
});
|
||||
|
||||
it("flags the toward-viewer axis as front at identity (Z points at the camera)", () => {
|
||||
const z = projectAxes(0, 0, 0, OPTS).find((a) => a.id === "z")!;
|
||||
expect(z.front).toBe(true);
|
||||
});
|
||||
|
||||
it("rotating 180° on Y flips the Z axis away from the viewer", () => {
|
||||
const z = projectAxes(0, 180, 0, OPTS).find((a) => a.id === "z")!;
|
||||
expect(z.front).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotate", () => {
|
||||
it("90° on Y maps +x to -z (right edge swings to the back)", () => {
|
||||
const v = rotate({ x: 1, y: 0, z: 0 }, 0, 90, 0);
|
||||
expect(v.x).toBeCloseTo(0, 5);
|
||||
expect(v.z).toBeCloseTo(-1, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapDeg", () => {
|
||||
it("wraps into (-180, 180]", () => {
|
||||
expect(wrapDeg(0)).toBe(0);
|
||||
expect(wrapDeg(180)).toBe(180);
|
||||
expect(wrapDeg(190)).toBe(-170);
|
||||
expect(wrapDeg(-190)).toBe(170);
|
||||
expect(wrapDeg(360)).toBe(0);
|
||||
expect(wrapDeg(540)).toBe(180);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Pure cube projection for the 3D-transform widget. Given an element's
|
||||
* rotationX/Y/Z (degrees), project a unit cube to 2D SVG polygons with
|
||||
* back-face culling and painter's-order sorting — no DOM, no React, so it
|
||||
* unit-tests in isolation. Used by Transform3DCube to draw a live preview of
|
||||
* the element's orientation.
|
||||
*/
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface ProjectedFace {
|
||||
/** Stable face id (front/back/left/right/top/bottom) for keying + theming. */
|
||||
id: string;
|
||||
/** SVG polygon points: "x,y x,y x,y x,y". */
|
||||
points: string;
|
||||
/** 0..1 brightness from how front-facing the rotated normal is (lighting cue). */
|
||||
shade: number;
|
||||
/** Mean depth of the face's corners; larger = nearer the viewer. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/** Rotate a point intrinsically by X, then Y, then Z (degrees). */
|
||||
export function rotate(v: Vec3, rx: number, ry: number, rz: number): Vec3 {
|
||||
let { x, y, z } = v;
|
||||
const cx = Math.cos(rx * DEG);
|
||||
const sx = Math.sin(rx * DEG);
|
||||
[y, z] = [y * cx - z * sx, y * sx + z * cx];
|
||||
const cy = Math.cos(ry * DEG);
|
||||
const sy = Math.sin(ry * DEG);
|
||||
[x, z] = [x * cy + z * sy, -x * sy + z * cy];
|
||||
const cz = Math.cos(rz * DEG);
|
||||
const sz = Math.sin(rz * DEG);
|
||||
[x, y] = [x * cz - y * sz, x * sz + y * cz];
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
// Unit-cube corners (±1) — 0-3 back face (z=-1), 4-7 front face (z=1).
|
||||
const CORNERS: Vec3[] = [
|
||||
{ x: -1, y: -1, z: -1 },
|
||||
{ x: 1, y: -1, z: -1 },
|
||||
{ x: 1, y: 1, z: -1 },
|
||||
{ x: -1, y: 1, z: -1 },
|
||||
{ x: -1, y: -1, z: 1 },
|
||||
{ x: 1, y: -1, z: 1 },
|
||||
{ x: 1, y: 1, z: 1 },
|
||||
{ x: -1, y: 1, z: 1 },
|
||||
];
|
||||
|
||||
const FACES: { id: string; idx: [number, number, number, number]; normal: Vec3 }[] = [
|
||||
{ id: "front", idx: [4, 5, 6, 7], normal: { x: 0, y: 0, z: 1 } },
|
||||
{ id: "back", idx: [1, 0, 3, 2], normal: { x: 0, y: 0, z: -1 } },
|
||||
{ id: "left", idx: [0, 4, 7, 3], normal: { x: -1, y: 0, z: 0 } },
|
||||
{ id: "right", idx: [5, 1, 2, 6], normal: { x: 1, y: 0, z: 0 } },
|
||||
// y=+1 corners project to screen-top (SVG y is flipped), so that face is "top".
|
||||
{ id: "top", idx: [7, 6, 2, 3], normal: { x: 0, y: 1, z: 0 } },
|
||||
{ id: "bottom", idx: [4, 5, 1, 0], normal: { x: 0, y: -1, z: 0 } },
|
||||
];
|
||||
|
||||
export interface ProjectOpts {
|
||||
/** Center of the SVG viewport. */
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Half-extent of the cube in SVG units (drawn cube ≈ 2·r before perspective). */
|
||||
r: number;
|
||||
/** Weak-perspective strength in units of `r` (larger = flatter; ~3-6 reads as 3D). */
|
||||
persp?: number;
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
// Directional light (upper-front-right), normalized — drives per-face shading so
|
||||
// top/front/side read as distinct planes instead of one flat fill.
|
||||
const LIGHT = (() => {
|
||||
const v = { x: 0.32, y: 0.56, z: 0.76 };
|
||||
const m = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / m, y: v.y / m, z: v.z / m };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Project the cube at the given orientation. Returns only the front-facing
|
||||
* faces (≤3), painter-sorted far→near so the SVG draws nearer faces on top.
|
||||
* Screen Y is flipped (SVG y grows downward). `shade` is a 0..1 lambert term
|
||||
* from {@link LIGHT} for clean directional face tones.
|
||||
*/
|
||||
export function projectCubeFaces(
|
||||
rx: number,
|
||||
ry: number,
|
||||
rz: number,
|
||||
opts: ProjectOpts,
|
||||
): ProjectedFace[] {
|
||||
const { cx, cy, r } = opts;
|
||||
const persp = opts.persp ?? 4;
|
||||
const view = (v: Vec3) => rotate(v, rx, ry, rz);
|
||||
const rotated = CORNERS.map(view);
|
||||
const faces: ProjectedFace[] = [];
|
||||
for (const f of FACES) {
|
||||
const n = view(f.normal);
|
||||
if (n.z <= 1e-6) continue; // back-face cull: normal must point toward viewer
|
||||
const corners = f.idx.map((i) => rotated[i]!);
|
||||
const depth = corners.reduce((s, p) => s + p.z, 0) / 4;
|
||||
const points = corners
|
||||
.map((p) => {
|
||||
// Weak perspective: nearer corners (higher z) project slightly larger.
|
||||
const s = persp / (persp - p.z);
|
||||
return `${round(cx + p.x * r * s)},${round(cy - p.y * r * s)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const lum = Math.max(0, n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z);
|
||||
faces.push({ id: f.id, points, shade: 0.3 + lum * 0.7, depth });
|
||||
}
|
||||
faces.sort((a, b) => a.depth - b.depth);
|
||||
return faces;
|
||||
}
|
||||
|
||||
export interface ProjectedAxis {
|
||||
id: "x" | "y" | "z";
|
||||
/** Axis color (standard X=red, Y=green, Z=blue). */
|
||||
color: string;
|
||||
/** Tip position in SVG coords. */
|
||||
x2: number;
|
||||
y2: number;
|
||||
/** Whether the tip points toward the viewer (drawn in front of the cube). */
|
||||
front: boolean;
|
||||
}
|
||||
|
||||
const AXES: { id: "x" | "y" | "z"; dir: Vec3; color: string }[] = [
|
||||
{ id: "x", dir: { x: 1, y: 0, z: 0 }, color: "#ff6b81" },
|
||||
{ id: "y", dir: { x: 0, y: 1, z: 0 }, color: "#5ff08a" },
|
||||
{ id: "z", dir: { x: 0, y: 0, z: 1 }, color: "#62b6ff" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Project the 3 orientation axes (from the cube center) for an X/Y/Z gizmo.
|
||||
* Orthographic — thin lines don't need perspective. `front` lets the caller draw
|
||||
* away-facing axes behind the cube and toward-facing axes on top.
|
||||
*/
|
||||
export function projectAxes(
|
||||
rx: number,
|
||||
ry: number,
|
||||
rz: number,
|
||||
opts: ProjectOpts,
|
||||
): ProjectedAxis[] {
|
||||
const { cx, cy, r } = opts;
|
||||
const len = r * 1.5;
|
||||
const view = (v: Vec3) => rotate(v, rx, ry, rz);
|
||||
return AXES.map((a) => {
|
||||
const p = view(a.dir);
|
||||
return {
|
||||
id: a.id,
|
||||
color: a.color,
|
||||
x2: round(cx + p.x * len),
|
||||
y2: round(cy - p.y * len),
|
||||
front: p.z >= -1e-6,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap an angle to (-180, 180] so drag accumulation never runs away. */
|
||||
export function wrapDeg(deg: number): number {
|
||||
let d = deg % 360;
|
||||
if (d > 180) d -= 360;
|
||||
if (d <= -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
Reference in New Issue
Block a user