@hyperframes/studio 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
  2. package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
  3. package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2715 -2061
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/manualOffsetDrag.test.ts +99 -0
  18. package/src/components/editor/manualOffsetDrag.ts +38 -7
  19. package/src/components/editor/marqueeCommit.ts +63 -20
  20. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  21. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  22. package/src/components/editor/transform3dProjection.test.ts +99 -0
  23. package/src/components/editor/transform3dProjection.ts +172 -0
  24. package/src/components/sidebar/AssetsTab.tsx +23 -213
  25. package/src/components/sidebar/AudioRow.tsx +202 -0
  26. package/src/contexts/DomEditContext.tsx +4 -0
  27. package/src/hooks/gsapDragCommit.test.ts +9 -5
  28. package/src/hooks/gsapDragCommit.ts +28 -9
  29. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  30. package/src/hooks/gsapRuntimePatch.ts +162 -35
  31. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  32. package/src/hooks/useDomEditCommits.ts +0 -2
  33. package/src/hooks/useDomEditSession.ts +4 -2
  34. package/src/hooks/useDomGeometryCommits.ts +3 -31
  35. package/src/hooks/useEnableKeyframes.test.ts +40 -0
  36. package/src/hooks/useEnableKeyframes.ts +9 -2
  37. package/src/hooks/useGsapAwareEditing.ts +31 -1
  38. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  39. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  40. package/src/hooks/useGsapTweenCache.ts +6 -4
  41. package/src/utils/marqueeGeometry.test.ts +15 -98
  42. package/src/utils/marqueeGeometry.ts +1 -158
  43. package/dist/assets/index-BSkUuN8g.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,9 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/core": "0.7.7",
49
- "@hyperframes/sdk": "0.7.7",
50
- "@hyperframes/player": "0.7.7"
48
+ "@hyperframes/core": "0.7.9",
49
+ "@hyperframes/sdk": "0.7.9",
50
+ "@hyperframes/player": "0.7.9"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/react": "19",
@@ -62,7 +62,7 @@
62
62
  "vite": "^6.4.2",
63
63
  "vitest": "^3.2.4",
64
64
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.7.7"
65
+ "@hyperframes/producer": "0.7.9"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
@@ -118,6 +118,7 @@ export function StudioRightPanel({
118
118
  handleGsapAddFromProperty,
119
119
  handleGsapRemoveFromProperty,
120
120
  commitAnimatedProperty,
121
+ commitAnimatedProperties,
121
122
  handleSetArcPath,
122
123
  handleUpdateArcSegment,
123
124
  handleUnroll,
@@ -269,9 +270,12 @@ export function StudioRightPanel({
269
270
  onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
270
271
  onAddGsapAnimation={handleGsapAddAnimation}
271
272
  onCommitAnimatedProperty={commitAnimatedProperty}
273
+ onCommitAnimatedProperties={commitAnimatedProperties}
272
274
  onAddKeyframe={handleGsapAddKeyframe}
273
275
  onRemoveKeyframe={handleGsapRemoveKeyframe}
274
- onConvertToKeyframes={handleGsapConvertToKeyframes}
276
+ onConvertToKeyframes={(animId, duration) =>
277
+ handleGsapConvertToKeyframes(animId, undefined, duration)
278
+ }
275
279
  onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
276
280
  onSetArcPath={handleSetArcPath}
277
281
  onUpdateArcSegment={handleUpdateArcSegment}
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
5
+ import { MarqueeOverlay } from "./MarqueeOverlay";
5
6
  import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
6
7
  import { collectDomEditLayerItems } from "./domEditingLayers";
7
8
  import { isElementComputedVisible } from "./domEditingElement";
@@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
101
102
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
102
103
  onMarqueeSelectRef.current = onMarqueeSelect;
103
104
 
105
+ // fallow-ignore-next-line complexity
104
106
  const selectionShapeStyles = (() => {
105
107
  const fallback = {
106
108
  borderRadius: 8 as string | number,
@@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
226
228
  // outside the composition bounds so users can find them.
227
229
  const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
228
230
  const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
231
+ // fallow-ignore-next-line complexity
229
232
  useEffect(() => {
230
233
  const iframe = iframeRef.current;
231
234
  const overlay = overlayRef.current;
@@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
568
571
  activeCompositionPathRef={activeCompositionPathRef}
569
572
  onSelectionChangeRef={onSelectionChangeRef}
570
573
  />
571
- {marquee.marqueeRect && (
572
- <div
573
- aria-hidden="true"
574
- className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
575
- style={{
576
- left: marquee.marqueeRect.left,
577
- top: marquee.marqueeRect.top,
578
- width: marquee.marqueeRect.width,
579
- height: marquee.marqueeRect.height,
580
- }}
581
- />
582
- )}
574
+ <MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
583
575
  <GridOverlay
584
576
  visible={gridVisible}
585
577
  spacing={gridSpacing}
@@ -0,0 +1,40 @@
1
+ import type { Rect } from "../../utils/marqueeGeometry";
2
+
3
+ interface MarqueeOverlayProps {
4
+ /** Elements the marquee currently intersects — outlined live before mouse-up. */
5
+ candidateRects: Rect[];
6
+ /** The marquee drag rectangle itself, or null when not marquee-selecting. */
7
+ marqueeRect: Rect | null;
8
+ }
9
+
10
+ /**
11
+ * The marquee selection visuals: a live "candidate" outline on each element the
12
+ * box currently touches, plus the dashed drag rectangle. Extracted from
13
+ * DomEditOverlay to keep that file under the 600-line cap.
14
+ */
15
+ export function MarqueeOverlay({ candidateRects, marqueeRect }: MarqueeOverlayProps) {
16
+ return (
17
+ <>
18
+ {candidateRects.map((r, i) => (
19
+ <div
20
+ key={i}
21
+ aria-hidden="true"
22
+ className="pointer-events-none absolute rounded-sm border border-studio-accent bg-studio-accent/5"
23
+ style={{ left: r.left, top: r.top, width: r.width, height: r.height }}
24
+ />
25
+ ))}
26
+ {marqueeRect && (
27
+ <div
28
+ aria-hidden="true"
29
+ className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
30
+ style={{
31
+ left: marqueeRect.left,
32
+ top: marqueeRect.top,
33
+ width: marqueeRect.width,
34
+ height: marqueeRect.height,
35
+ }}
36
+ />
37
+ )}
38
+ </>
39
+ );
40
+ }
@@ -24,7 +24,8 @@ interface OffCanvasIndicatorsProps {
24
24
  /**
25
25
  * Dashed teal indicators for elements whose bounds extend past the composition
26
26
  * (the "gray zone"). The in-canvas portion is clipped away so only the
27
- * protruding sliver is dashed; the inside portion gets a solid outline.
27
+ * protruding sliver is dashed the on-canvas part gets no outline, since a
28
+ * solid selection-style border on an unselected element reads as "selected".
28
29
  * Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
29
30
  */
30
31
  export function OffCanvasIndicators({
@@ -57,7 +58,6 @@ export function OffCanvasIndicators({
57
58
  const clipOutside = hasInside
58
59
  ? `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)`
59
60
  : undefined;
60
- const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
61
61
  const selectOffCanvas = async () => {
62
62
  const el = elements.current.get(r.key);
63
63
  if (!el) return;
@@ -98,11 +98,6 @@ export function OffCanvasIndicators({
98
98
  }
99
99
  }}
100
100
  />
101
- {/* Solid layer — clipped to canvas bounds, covers inside portion */}
102
- <div
103
- 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)]"
104
- style={{ clipPath: clipInside }}
105
- />
106
101
  </div>
107
102
  );
108
103
  })}
@@ -90,6 +90,7 @@ export const PropertyPanel = memo(function PropertyPanel({
90
90
  onRemoveKeyframe,
91
91
  onConvertToKeyframes,
92
92
  onCommitAnimatedProperty,
93
+ onCommitAnimatedProperties,
93
94
  onSeekToTime,
94
95
  recordingState,
95
96
  recordingDuration,
@@ -523,9 +524,20 @@ export const PropertyPanel = memo(function PropertyPanel({
523
524
  elDuration={elDuration}
524
525
  element={element}
525
526
  onCommitAnimatedProperty={onCommitAnimatedProperty}
527
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
526
528
  onSeekToTime={onSeekToTime}
527
529
  onRemoveKeyframe={onRemoveKeyframe}
528
530
  onConvertToKeyframes={onConvertToKeyframes}
531
+ onLivePreviewProps={(el, props) => {
532
+ const iframe = iframeRef.current;
533
+ const win = iframe?.contentWindow as
534
+ | { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
535
+ | null
536
+ | undefined;
537
+ const sel = el.id ? `#${el.id}` : el.selector;
538
+ const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
539
+ if (win?.gsap && node) win.gsap.set(node, props);
540
+ }}
529
541
  />
530
542
  )}
531
543
  <div className="mt-3">
@@ -0,0 +1,313 @@
1
+ import { useRef, useState } from "react";
2
+ import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
3
+
4
+ export interface CubePose {
5
+ rotationX: number;
6
+ rotationY: number;
7
+ rotationZ: number;
8
+ }
9
+
10
+ const VIEW_W = 132;
11
+ const VIEW_H = 112;
12
+ const CX = VIEW_W / 2;
13
+ const CY = 54;
14
+ const RADIUS = 26;
15
+ // The cube mirrors the element's orientation 1:1 — no decorative viewing camera,
16
+ // so at rotation 0/0/0 it faces front (flat) exactly like the un-rotated element.
17
+ // The X/Y/Z axis gizmo keeps the flat-at-rest state readable.
18
+ const SENSITIVITY = 0.6; // degrees per pixel of drag
19
+
20
+ /**
21
+ * Draggable 3D-orientation cube. Drag to tilt (X/Y); Shift-drag to roll (Z).
22
+ * Presentational only: emits a live draft pose while dragging and a final pose
23
+ * on release — the parent owns live-previewing and committing to GSAP props.
24
+ */
25
+ // transformPerspective (px) is inversely related to effect strength, with 0 = off.
26
+ // Map a 0..1 slider strength to px and to the cube's weak-perspective projection.
27
+ const STRONG_PX = 200;
28
+ const WEAK_PX = 1600;
29
+ const PX_RANGE = WEAK_PX - STRONG_PX;
30
+ const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE));
31
+ const pxToStrength = (px: number) =>
32
+ px <= 0
33
+ ? 0
34
+ : Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE));
35
+ const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
36
+
37
+ /** Horizontal "perspective strength" slider — left = none, right = dramatic. */
38
+ function PerspectiveSlider({
39
+ value,
40
+ onDraft,
41
+ onCommit,
42
+ }: {
43
+ value: number;
44
+ onDraft?: (px: number) => void;
45
+ onCommit: (px: number) => void;
46
+ }) {
47
+ const trackRef = useRef<HTMLDivElement | null>(null);
48
+ const draggingRef = useRef(false);
49
+ const strength = pxToStrength(value);
50
+ const fromEvent = (clientX: number) => {
51
+ const r = trackRef.current?.getBoundingClientRect();
52
+ if (!r || r.width === 0) return 0;
53
+ return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width)));
54
+ };
55
+ return (
56
+ <div className="flex items-center gap-1.5 px-2 pb-1.5 pt-1">
57
+ <span className="text-[8px] font-medium uppercase tracking-wide text-neutral-600">Persp</span>
58
+ <div
59
+ ref={trackRef}
60
+ onPointerDown={(e) => {
61
+ e.currentTarget.setPointerCapture(e.pointerId);
62
+ draggingRef.current = true;
63
+ onDraft?.(fromEvent(e.clientX));
64
+ }}
65
+ onPointerMove={(e) => {
66
+ if (draggingRef.current) onDraft?.(fromEvent(e.clientX));
67
+ }}
68
+ onPointerUp={(e) => {
69
+ if (!draggingRef.current) return;
70
+ draggingRef.current = false;
71
+ onCommit(fromEvent(e.clientX));
72
+ }}
73
+ onPointerCancel={() => {
74
+ draggingRef.current = false;
75
+ }}
76
+ className="relative h-3 flex-1 cursor-ew-resize touch-none"
77
+ >
78
+ <div className="absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-neutral-700" />
79
+ <div
80
+ className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-[#5ff0bf]"
81
+ style={{ width: `${strength * 100}%` }}
82
+ />
83
+ <div
84
+ className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-900 bg-[#5ff0bf]"
85
+ style={{ left: `${strength * 100}%` }}
86
+ />
87
+ </div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ export function Transform3DCube({
93
+ pose,
94
+ perspective = 0,
95
+ onPoseDraft,
96
+ onPoseCommit,
97
+ onPerspectiveDraft,
98
+ onPerspectiveCommit,
99
+ onRecenter,
100
+ onKeyframe,
101
+ keyframed,
102
+ }: {
103
+ pose: CubePose;
104
+ /** Element's transformPerspective (px); drives the cube's foreshortening. */
105
+ perspective?: number;
106
+ /** Fires on every drag move with the in-progress pose (parent live-previews). */
107
+ onPoseDraft?: (pose: CubePose) => void;
108
+ /** Fires once on pointer release with the final pose (commit). */
109
+ onPoseCommit: (pose: CubePose) => void;
110
+ /** Live + committed perspective (px) from the in-cube slider. */
111
+ onPerspectiveDraft?: (px: number) => void;
112
+ onPerspectiveCommit?: (px: number) => void;
113
+ /** Reset to identity orientation. */
114
+ onRecenter?: () => void;
115
+ /** Toggle keyframing the 3D transform (convert the static set → keyframes). */
116
+ onKeyframe?: () => void;
117
+ /** Whether the 3D transform is already keyframed (drives the toggle's state). */
118
+ keyframed?: boolean;
119
+ }) {
120
+ const [draft, setDraft] = useState<CubePose | null>(null);
121
+ const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
122
+ const shown = draft ?? pose;
123
+ const projOpts = {
124
+ cx: CX,
125
+ cy: CY,
126
+ r: RADIUS,
127
+ persp: pxToProjPersp(perspective),
128
+ };
129
+ const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
130
+ const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
131
+
132
+ const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
133
+ e.currentTarget.setPointerCapture(e.pointerId);
134
+ dragRef.current = { x: e.clientX, y: e.clientY, pose: shown };
135
+ setDraft(shown);
136
+ };
137
+
138
+ const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
139
+ const d = dragRef.current;
140
+ if (!d) return;
141
+ const dx = e.clientX - d.x;
142
+ const dy = e.clientY - d.y;
143
+ const next: CubePose = e.shiftKey
144
+ ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) }
145
+ : {
146
+ rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY),
147
+ rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
148
+ rotationZ: d.pose.rotationZ,
149
+ };
150
+ setDraft(next);
151
+ onPoseDraft?.(next);
152
+ };
153
+
154
+ const onPointerUp = () => {
155
+ if (!dragRef.current) return;
156
+ dragRef.current = null;
157
+ if (draft) onPoseCommit(draft);
158
+ setDraft(null);
159
+ };
160
+
161
+ return (
162
+ <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
163
+ <svg
164
+ viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
165
+ className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
166
+ style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
167
+ onPointerDown={onPointerDown}
168
+ onPointerMove={onPointerMove}
169
+ onPointerUp={onPointerUp}
170
+ onPointerCancel={onPointerUp}
171
+ role="slider"
172
+ aria-label="Drag to rotate in 3D; hold Shift to roll"
173
+ aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
174
+ shown.rotationY,
175
+ )}°, Z ${Math.round(shown.rotationZ)}°`}
176
+ >
177
+ <defs>
178
+ <radialGradient id="cube3d-bg" cx="50%" cy="40%" r="65%">
179
+ <stop offset="0%" stopColor="#172220" />
180
+ <stop offset="100%" stopColor="#070a09" />
181
+ </radialGradient>
182
+ {/* Soft halo so the cube floats; SourceGraphic stays crisp on top. */}
183
+ <filter id="cube3d-glow" x="-40%" y="-40%" width="180%" height="180%">
184
+ <feGaussianBlur stdDeviation="1.4" result="blur" />
185
+ <feMerge>
186
+ <feMergeNode in="blur" />
187
+ <feMergeNode in="SourceGraphic" />
188
+ </feMerge>
189
+ </filter>
190
+ </defs>
191
+ <rect x="0" y="0" width={VIEW_W} height={VIEW_H} fill="url(#cube3d-bg)" />
192
+ {/* Grounding shadow under the cube. */}
193
+ <ellipse
194
+ cx={CX}
195
+ cy={CY + RADIUS + 22}
196
+ rx={RADIUS * 1.2}
197
+ ry={6.5}
198
+ fill="#000"
199
+ opacity={0.4}
200
+ />
201
+ {/* Away-facing axes are drawn behind the cube, dimmed. */}
202
+ {axes
203
+ .filter((a) => !a.front)
204
+ .map((a) => (
205
+ <line
206
+ key={a.id}
207
+ x1={CX}
208
+ y1={CY}
209
+ x2={a.x2}
210
+ y2={a.y2}
211
+ stroke={a.color}
212
+ strokeWidth={1.4}
213
+ strokeLinecap="round"
214
+ opacity={0.3}
215
+ />
216
+ ))}
217
+ <g filter="url(#cube3d-glow)">
218
+ {faces.map((f) => (
219
+ <polygon
220
+ key={f.id}
221
+ points={f.points}
222
+ // Muted teal face, lit by direction; edges are a soft mint that
223
+ // brightens with how front-facing the face is, so corners read as
224
+ // crisp bevels instead of flat neon outlines.
225
+ fill={`hsl(166 44% ${Math.round(17 + f.shade * 37)}%)`}
226
+ stroke={`hsl(164 72% ${Math.round(56 + f.shade * 22)}%)`}
227
+ strokeWidth={1.1}
228
+ strokeOpacity={0.82}
229
+ strokeLinejoin="round"
230
+ strokeLinecap="round"
231
+ paintOrder="stroke"
232
+ />
233
+ ))}
234
+ </g>
235
+ {/* Toward-facing axes on top, with a tip dot + X/Y/Z label. */}
236
+ {axes
237
+ .filter((a) => a.front)
238
+ .map((a) => (
239
+ <g key={a.id}>
240
+ <line
241
+ x1={CX}
242
+ y1={CY}
243
+ x2={a.x2}
244
+ y2={a.y2}
245
+ stroke={a.color}
246
+ strokeWidth={1.6}
247
+ strokeLinecap="round"
248
+ opacity={0.95}
249
+ />
250
+ <circle cx={a.x2} cy={a.y2} r={2.4} fill={a.color} />
251
+ <text
252
+ x={a.x2 + (a.x2 - CX) * 0.12}
253
+ y={a.y2 + (a.y2 - CY) * 0.12 + 2}
254
+ fill={a.color}
255
+ fontSize={7}
256
+ fontWeight={700}
257
+ textAnchor="middle"
258
+ >
259
+ {a.id.toUpperCase()}
260
+ </text>
261
+ </g>
262
+ ))}
263
+ </svg>
264
+ {onRecenter && (
265
+ <button
266
+ type="button"
267
+ onClick={onRecenter}
268
+ title="Reset 3D orientation"
269
+ aria-label="Reset 3D orientation"
270
+ className="absolute right-1.5 top-1.5 rounded p-0.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
271
+ >
272
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor">
273
+ <circle cx="12" cy="12" r="9" strokeWidth="2" />
274
+ <path d="M12 3v18M3 12h18" strokeWidth="1.5" />
275
+ </svg>
276
+ </button>
277
+ )}
278
+ {onKeyframe && (
279
+ <button
280
+ type="button"
281
+ onClick={onKeyframe}
282
+ title={
283
+ keyframed
284
+ ? "3D transform is keyframed — click a field diamond to add keyframes"
285
+ : "Keyframe the 3D transform (animate it over time)"
286
+ }
287
+ aria-label="Keyframe 3D transform"
288
+ aria-pressed={keyframed}
289
+ className={`absolute left-1.5 top-1.5 rounded p-0.5 hover:bg-neutral-800 ${
290
+ keyframed ? "text-[#5ff0bf]" : "text-neutral-500 hover:text-neutral-200"
291
+ }`}
292
+ >
293
+ <svg
294
+ width="12"
295
+ height="12"
296
+ viewBox="0 0 12 12"
297
+ fill={keyframed ? "currentColor" : "none"}
298
+ stroke="currentColor"
299
+ >
300
+ <path d="M6 1.5L10.5 6 6 10.5 1.5 6z" strokeWidth="1.4" strokeLinejoin="round" />
301
+ </svg>
302
+ </button>
303
+ )}
304
+ {onPerspectiveCommit && (
305
+ <PerspectiveSlider
306
+ value={perspective}
307
+ onDraft={onPerspectiveDraft}
308
+ onCommit={onPerspectiveCommit}
309
+ />
310
+ )}
311
+ </div>
312
+ );
313
+ }
@@ -25,6 +25,7 @@ export const PROP_LABELS: Record<string, string> = {
25
25
  rotationY: "Rotate Y",
26
26
  rotationZ: "Rotate Z",
27
27
  perspective: "Perspective",
28
+ transformPerspective: "Perspective",
28
29
  transformOrigin: "Transform Origin",
29
30
  opacity: "Opacity",
30
31
  scale: "Scale",
@@ -57,6 +58,7 @@ export const PROP_UNITS: Record<string, string> = {
57
58
  rotationY: "°",
58
59
  rotationZ: "°",
59
60
  perspective: "px",
61
+ transformPerspective: "px",
60
62
  transformOrigin: "",
61
63
  opacity: "%",
62
64
  scale: "×",
@@ -80,6 +82,8 @@ export const PROP_TOOLTIPS: Record<string, string> = {
80
82
  rotationZ: "Rotate around the screen-facing Z axis",
81
83
  perspective:
82
84
  "3D depth context for child elements; set it on a parent when rotating children in 3D",
85
+ transformPerspective:
86
+ "3D depth for THIS element's own X/Y rotation — lower = stronger perspective (try 600–1000)",
83
87
  transformOrigin: "Pivot point for transforms, for example center center or 50% 50%",
84
88
  width: "Element width",
85
89
  height: "Element height",
@@ -150,6 +154,7 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
150
154
  rotationY: { step: 1 },
151
155
  rotationZ: { step: 1 },
152
156
  perspective: { min: 0, step: 1 },
157
+ transformPerspective: { min: 0, step: 1 },
153
158
  skewX: { min: -90, max: 90, step: 1 },
154
159
  skewY: { min: -90, max: 90, step: 1 },
155
160
  width: { min: 0, step: 1 },
@@ -2,6 +2,7 @@ import { Window } from "happy-dom";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  applyManualOffsetDragCommit,
5
+ applyManualOffsetDragDraft,
5
6
  applyManualOffsetDragMatrix,
6
7
  createManualOffsetDragMember,
7
8
  endManualOffsetDragMembers,
@@ -261,3 +262,101 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
261
262
  }
262
263
  });
263
264
  });
265
+
266
+ // ── GSAP-element drag: the dot-a "flies" regressions ────────────────────────
267
+ // A static element positioned via the legacy `--hf-studio-offset` CSS var, dragged
268
+ // in a GSAP composition. Three independent failure modes, each fixed:
269
+ // 1. live drag integrated off-screen (base read from the live transform)
270
+ // 2. commit re-added the delta (stamped base wiped by a mid-drag re-render)
271
+ // 3. drop left the element offset (stale --hf-studio-offset var composing with
272
+ // the committed GSAP transform until a full reload)
273
+ function makeGsapDot(offsetX = 94, offsetY = 2) {
274
+ const window = new Window();
275
+ const element = window.document.createElement("div");
276
+ element.id = "dot-a";
277
+ element.setAttribute("data-hf-studio-path-offset", "true");
278
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, `${offsetX}px`);
279
+ element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${offsetY}px`);
280
+ element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
281
+ window.document.body.append(element);
282
+ // Constant rect → the screen-to-offset probe can't measure movement → member
283
+ // uses the deterministic preview-scale fallback matrix. Both branches set baseGsap.
284
+ element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
285
+ const sets: Array<Record<string, unknown>> = [];
286
+ const win = element.ownerDocument.defaultView as unknown as {
287
+ gsap?: unknown;
288
+ __timelines?: unknown;
289
+ };
290
+ win.gsap = {
291
+ set: (el: HTMLElement, vars: Record<string, unknown>) => {
292
+ sets.push({ ...vars });
293
+ if (typeof vars.x === "number") {
294
+ el.style.setProperty("transform", `translate(${vars.x}px, ${(vars.y as number) ?? 0}px)`);
295
+ }
296
+ },
297
+ // getProperty reads the LIVE transform — the exact value the old code fed back
298
+ // into `base + delta`, integrating the element off-screen.
299
+ getProperty: (el: HTMLElement, prop: string) => {
300
+ const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(
301
+ el.style.getPropertyValue("transform") || "",
302
+ );
303
+ if (!m) return 0;
304
+ return prop === "x" ? Number.parseFloat(m[1]!) : Number.parseFloat(m[2]!);
305
+ },
306
+ };
307
+ const member = () => {
308
+ const result = createManualOffsetDragMember({
309
+ key: "dot",
310
+ selection: { element } as never,
311
+ element,
312
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
313
+ });
314
+ if (!result.ok) throw new Error("member not created");
315
+ return result.member;
316
+ };
317
+ return { element, sets, member };
318
+ }
319
+
320
+ describe("GSAP-element drag — dot-a flies regressions", () => {
321
+ it("live draft uses the stable gesture-start base, so repeated moves don't integrate", () => {
322
+ const { element, member } = makeGsapDot();
323
+ const m = member();
324
+ // Simulate a mid-drag re-render wiping the stamped base attr → the draft must
325
+ // fall back to the in-memory member.baseGsap, NOT the live (mutating) transform.
326
+ element.removeAttribute("data-hf-drag-gsap-base-x");
327
+ element.removeAttribute("data-hf-drag-gsap-base-y");
328
+ applyManualOffsetDragDraft(m, -50, 0);
329
+ const first = element.style.getPropertyValue("transform");
330
+ applyManualOffsetDragDraft(m, -50, 0);
331
+ const second = element.style.getPropertyValue("transform");
332
+ // Same pointer delta → same committed transform. The old bug integrated (the
333
+ // second frame added the delta on top of the first frame's result).
334
+ expect(second).toBe(first);
335
+ });
336
+
337
+ it("commit re-stamps the stable base/initial attrs even after they're wiped", () => {
338
+ const { element, member } = makeGsapDot();
339
+ const m = member();
340
+ element.removeAttribute("data-hf-drag-gsap-base-x");
341
+ element.removeAttribute("data-hf-drag-initial-offset-x");
342
+ applyManualOffsetDragCommit(m, -50, 0);
343
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe(String(m.baseGsap.x));
344
+ expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe(String(m.initialOffset.x));
345
+ });
346
+
347
+ it("a GSAP-committed drag migrates the element off --hf-studio-offset", () => {
348
+ const { element, member } = makeGsapDot();
349
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("94px");
350
+ const m = member();
351
+ applyManualOffsetDragCommit(m, -160, 0);
352
+ endManualOffsetDragMembers([m]);
353
+ // The legacy CSS-offset channel is fully cleared (single-sourced in GSAP): the
354
+ // var is removed, so any lingering `translate: var(--hf-studio-offset-x, 0px)`
355
+ // resolves to its 0px fallback and can no longer compose with the GSAP transform.
356
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
357
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
358
+ expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
359
+ // ...and the position survives in the GSAP transform (no stale var to compose).
360
+ expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
361
+ });
362
+ });