@hyperframes/studio 0.7.14 → 0.7.16

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 (72) hide show
  1. package/dist/assets/index-C4cUbqql.js +375 -0
  2. package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
  5. package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +23 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3158 -2809
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -5
  13. package/src/App.tsx +2 -0
  14. package/src/components/StudioPreviewArea.tsx +0 -41
  15. package/src/components/StudioRightPanel.tsx +2 -0
  16. package/src/components/TimelineToolbar.tsx +4 -4
  17. package/src/components/editor/DomEditOverlay.tsx +5 -2
  18. package/src/components/editor/GestureRecordControl.tsx +2 -2
  19. package/src/components/editor/GsapAnimationSection.tsx +3 -3
  20. package/src/components/editor/InspectorHeaderActions.tsx +62 -0
  21. package/src/components/editor/LayersPanel.tsx +35 -2
  22. package/src/components/editor/MotionPathOverlay.tsx +24 -8
  23. package/src/components/editor/PropertyPanel.tsx +48 -60
  24. package/src/components/editor/Transform3DCube.tsx +84 -85
  25. package/src/components/editor/domEditOverlayGeometry.ts +28 -0
  26. package/src/components/editor/domEditingDom.ts +36 -2
  27. package/src/components/editor/domEditingGroups.ts +41 -0
  28. package/src/components/editor/domEditingLayers.test.ts +97 -1
  29. package/src/components/editor/domEditingLayers.ts +13 -29
  30. package/src/components/editor/domEditingTypes.ts +4 -1
  31. package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
  32. package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
  33. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  34. package/src/components/editor/manualEditsTypes.ts +1 -1
  35. package/src/components/editor/manualOffsetDrag.ts +23 -2
  36. package/src/components/editor/motionPathCommit.test.ts +1 -1
  37. package/src/components/editor/motionPathSelection.ts +1 -1
  38. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  39. package/src/components/editor/propertyPanelHelpers.ts +6 -4
  40. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  41. package/src/components/editor/useMotionPathData.ts +47 -1
  42. package/src/contexts/DomEditContext.tsx +16 -0
  43. package/src/contexts/TimelineEditContext.tsx +0 -1
  44. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  45. package/src/hooks/gsapTweenSynth.ts +64 -0
  46. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  47. package/src/hooks/useAppHotkeys.ts +34 -3
  48. package/src/hooks/useDomEditSession.ts +48 -0
  49. package/src/hooks/useDomSelection.ts +73 -9
  50. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  51. package/src/hooks/useEnableKeyframes.ts +29 -1
  52. package/src/hooks/useGroupCommits.ts +188 -0
  53. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  54. package/src/hooks/useGsapTweenCache.ts +56 -72
  55. package/src/hooks/usePreviewInteraction.ts +60 -2
  56. package/src/player/components/ShortcutsPanel.tsx +2 -0
  57. package/src/player/components/Timeline.tsx +0 -15
  58. package/src/player/components/TimelineCanvas.tsx +0 -13
  59. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  60. package/src/player/components/timelineCallbacks.ts +0 -1
  61. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  62. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  63. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  64. package/src/player/store/playerStore.ts +20 -0
  65. package/src/utils/sdkCutoverParity.test.ts +1 -1
  66. package/src/utils/studioPreviewHelpers.ts +37 -0
  67. package/dist/assets/index-BXG-pbr_.js +0 -375
  68. package/dist/assets/index-svYFaNuq.css +0 -1
  69. package/dist/chunk-KZXYQYIU.js.map +0 -1
  70. package/src/components/editor/keyframeMove.test.ts +0 -101
  71. package/src/components/editor/keyframeMove.ts +0 -151
  72. /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,11 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/core": "0.7.14",
49
- "@hyperframes/sdk": "0.7.14",
50
- "@hyperframes/player": "0.7.14"
48
+ "@hyperframes/core": "0.7.16",
49
+ "@hyperframes/player": "0.7.16",
50
+ "@hyperframes/parsers": "0.7.16",
51
+ "@hyperframes/studio-server": "0.7.16",
52
+ "@hyperframes/sdk": "0.7.16"
51
53
  },
52
54
  "devDependencies": {
53
55
  "@types/react": "19",
@@ -62,7 +64,7 @@
62
64
  "vite": "^6.4.2",
63
65
  "vitest": "^3.2.4",
64
66
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.7.14"
67
+ "@hyperframes/producer": "0.7.16"
66
68
  },
67
69
  "peerDependencies": {
68
70
  "react": "19",
package/src/App.tsx CHANGED
@@ -258,6 +258,8 @@ export function StudioApp() {
258
258
  onResetKeyframes: () => resetKeyframesRef.current(),
259
259
  onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
260
260
  onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
261
+ onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
262
+ onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
261
263
  activeCompPath,
262
264
  forceReloadSdkSession: sdkHandle.forceReload,
263
265
  onToggleRecording: STUDIO_KEYFRAMES_ENABLED
@@ -21,8 +21,6 @@ import { useDomEditActionsContext, useDomEditSelectionContext } from "../context
21
21
  import { TimelineEditProvider } from "../contexts/TimelineEditContext";
22
22
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
23
23
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
24
- import { fetchParsedAnimations } from "../hooks/useGsapTweenCache";
25
- import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove";
26
24
  import type { GestureRecordingState } from "./editor/GestureRecordControl";
27
25
 
28
26
  export interface StudioPreviewAreaProps {
@@ -182,45 +180,6 @@ export function StudioPreviewArea({
182
180
  }
183
181
  },
184
182
  // fallow-ignore-next-line complexity
185
- onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => {
186
- // Resolve the dragged element's selection + parsed animations on demand
187
- // (both awaited and cached) rather than relying on the async DOM-edit
188
- // session being loaded for this element — that coupling made the commit
189
- // intermittently no-op (revert) when dragging before the session caught up.
190
- if (!projectId) return;
191
- const sourceFile = _el.sourceFile || activeCompPath || "index.html";
192
- const [selection, parsed] = await Promise.all([
193
- buildDomSelectionForTimelineElement(_el),
194
- fetchParsedAnimations(projectId, sourceFile),
195
- ]);
196
- if (!selection || !parsed) return;
197
-
198
- const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id);
199
- const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
200
- const origAbsTime = _el.start + (oldPct / 100) * _el.duration;
201
- const anim = pickKeyframeTween(
202
- parsed.animations,
203
- _el,
204
- origAbsTime,
205
- cachedKf?.propertyGroup,
206
- );
207
- if (!anim) return;
208
-
209
- const plan = computeKeyframeMovePlan(
210
- anim,
211
- cachedKf?.tweenPercentage ?? oldPct,
212
- _el,
213
- newPct,
214
- );
215
- if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection);
216
- for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection);
217
- for (const add of plan.adds) {
218
- for (const [prop, val] of Object.entries(add.properties)) {
219
- handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection);
220
- }
221
- }
222
- },
223
- // fallow-ignore-next-line complexity
224
183
  onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
225
184
  const currentTime = usePlayerStore.getState().currentTime;
226
185
  const pct =
@@ -93,6 +93,7 @@ export function StudioRightPanel({
93
93
  domEditGroupSelections,
94
94
  copiedAgentPrompt,
95
95
  clearDomSelection,
96
+ handleUngroupSelection,
96
97
  handleDomStyleCommit,
97
98
  handleDomAttributeCommit,
98
99
  handleDomAttributeLiveCommit,
@@ -241,6 +242,7 @@ export function StudioRightPanel({
241
242
  multiSelectCount={domEditGroupSelections.length}
242
243
  copiedAgentPrompt={copiedAgentPrompt}
243
244
  onClearSelection={clearDomSelection}
245
+ onUngroup={handleUngroupSelection}
244
246
  onSetStyle={handleDomStyleCommit}
245
247
  onSetAttribute={handleDomAttributeCommit}
246
248
  onSetAttributeLive={handleDomAttributeLiveCommit}
@@ -133,12 +133,12 @@ export function TimelineToolbar({
133
133
  <Tooltip
134
134
  label={
135
135
  keyframeState === "active"
136
- ? "Remove keyframe at playhead"
136
+ ? "Remove keyframe at playhead (K)"
137
137
  : keyframeState === "inactive"
138
138
  ? keyframeWillExtend
139
- ? "Add keyframe at playhead (extends animation)"
140
- : "Add keyframe at playhead"
141
- : "Enable keyframes"
139
+ ? "Add keyframe at playhead extends animation (K)"
140
+ : "Add keyframe at playhead (K)"
141
+ : "Add keyframe (K)"
142
142
  }
143
143
  >
144
144
  <button
@@ -3,7 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
5
5
  import { MarqueeOverlay } from "./MarqueeOverlay";
6
- import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
6
+ import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
7
7
  import { collectDomEditLayerItems } from "./domEditingLayers";
8
8
  import { isElementComputedVisible } from "./domEditingElement";
9
9
  import {
@@ -248,7 +248,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
248
248
  const elMap = new Map<string, HTMLElement>();
249
249
  for (const item of items) {
250
250
  if (!isElementComputedVisible(item.element)) continue;
251
- const r = toOverlayRect(overlay, iframe, item.element);
251
+ // Groups use their members' union (where they actually render), so a group
252
+ // whose members sit inside the canvas isn't flagged off-canvas by a stale
253
+ // wrapper box.
254
+ const r = groupAwareOverlayRect(overlay, iframe, item.element);
252
255
  if (!r) continue;
253
256
  // Any edge crossing the composition border → gray-zone indicator (the
254
257
  // in-canvas portion is clipped away below, so only the sliver shows).
@@ -43,8 +43,8 @@ export function GestureRecordPanelButton({
43
43
  >
44
44
  <GestureRecordIcon recording={recording} />
45
45
  {recording
46
- ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s press R`
47
- : "Record gesture (R) move pointer to capture motion"}
46
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s -- press R`
47
+ : "Record gesture (R) -- move pointer to capture motion"}
48
48
  </button>
49
49
  </div>
50
50
  );
@@ -46,9 +46,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
46
46
  )}
47
47
  {unsupportedTimelinePattern && (
48
48
  <p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
49
- This composition uses a timeline assignment pattern (window.__timelines[...]) that the
50
- editor doesn&apos;t support. Use a variable declaration (const tl = gsap.timeline()) to
51
- enable editing.
49
+ This timeline uses a computed key (window.__timelines[variable]) the editor can&apos;t
50
+ resolve statically. Use a string-literal key (window.__timelines[&quot;id&quot;]) or a
51
+ variable declaration (const tl = gsap.timeline()) to enable editing.
52
52
  </p>
53
53
  )}
54
54
  {multipleTimelines || unsupportedTimelinePattern ? null : (
@@ -0,0 +1,62 @@
1
+ import { X } from "../../icons/SystemIcons";
2
+ import type { DomEditSelection } from "./domEditingTypes";
3
+
4
+ /** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
5
+ export function InspectorHeaderActions({
6
+ element,
7
+ copied,
8
+ onCopy,
9
+ onClear,
10
+ onUngroup,
11
+ }: {
12
+ element: DomEditSelection;
13
+ copied: boolean;
14
+ onCopy: () => void;
15
+ onClear: () => void;
16
+ onUngroup?: () => void;
17
+ }) {
18
+ return (
19
+ <div className="flex items-center gap-1">
20
+ {onUngroup && element.dataAttributes["hf-group"] != null && (
21
+ <button
22
+ type="button"
23
+ onClick={onUngroup}
24
+ title="Ungroup (⌘⇧G)"
25
+ className="flex h-6 items-center rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
26
+ >
27
+ Ungroup
28
+ </button>
29
+ )}
30
+ <button
31
+ type="button"
32
+ onClick={onCopy}
33
+ className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
34
+ copied
35
+ ? "text-studio-accent"
36
+ : "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
37
+ }`}
38
+ title={copied ? "Copied!" : "Copy element info to clipboard"}
39
+ >
40
+ <svg
41
+ width="13"
42
+ height="13"
43
+ viewBox="0 0 16 16"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ strokeWidth="1.5"
47
+ >
48
+ <rect x="5" y="5" width="9" height="9" rx="1.5" />
49
+ <path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
50
+ </svg>
51
+ </button>
52
+ <button
53
+ type="button"
54
+ aria-label="Clear selection"
55
+ onClick={onClear}
56
+ className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
57
+ >
58
+ <X size={13} />
59
+ </button>
60
+ </div>
61
+ );
62
+ }
@@ -59,9 +59,11 @@ export const LayersPanel = memo(function LayersPanel() {
59
59
  const currentTime = usePlayerStore((s) => s.currentTime);
60
60
  const {
61
61
  domEditSelection,
62
+ activeGroupElement,
62
63
  applyDomSelection,
63
64
  updateDomEditHoverSelection,
64
65
  handleDomZIndexReorderCommit,
66
+ setActiveGroupElement,
65
67
  } = useDomEditContext();
66
68
 
67
69
  const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
@@ -86,12 +88,16 @@ export const LayersPanel = memo(function LayersPanel() {
86
88
  doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
87
89
  if (!root) return;
88
90
 
91
+ // A preview reload detaches the drilled-into wrapper; exit drill-in if so.
92
+ if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null);
93
+
89
94
  const items = collectDomEditLayerItems(root, {
90
95
  activeCompositionPath: activeCompPath,
91
96
  isMasterView,
97
+ activeGroupElement,
92
98
  });
93
99
  setLayers(sortLayersByZIndex(items));
94
- }, [previewIframeRef, activeCompPath, isMasterView]);
100
+ }, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]);
95
101
 
96
102
  useEffect(() => {
97
103
  collectLayers();
@@ -135,9 +141,10 @@ export const LayersPanel = memo(function LayersPanel() {
135
141
  activeCompositionPath: activeCompPath,
136
142
  isMasterView,
137
143
  preferClipAncestor: false,
144
+ activeGroupElement,
138
145
  });
139
146
  },
140
- [activeCompPath, isMasterView, previewIframeRef],
147
+ [activeCompPath, isMasterView, previewIframeRef, activeGroupElement],
141
148
  );
142
149
 
143
150
  const seekToLayer = useCallback(
@@ -183,6 +190,19 @@ export const LayersPanel = memo(function LayersPanel() {
183
190
  [resolveSelection, applyDomSelection, seekToLayer],
184
191
  );
185
192
 
193
+ // Double-click a group row → drill into it; any other row → select it.
194
+ const handleLayerDoubleClick = useCallback(
195
+ async (layer: DomEditLayerItem) => {
196
+ const selection = await resolveSelection(layer);
197
+ if (selection?.element.hasAttribute("data-hf-group")) {
198
+ setActiveGroupElement(selection.element);
199
+ } else {
200
+ await handleSelectLayer(layer);
201
+ }
202
+ },
203
+ [resolveSelection, setActiveGroupElement, handleSelectLayer],
204
+ );
205
+
186
206
  const handleLayerHover = useCallback(
187
207
  async (layer: DomEditLayerItem | null) => {
188
208
  if (!layer) {
@@ -271,6 +291,18 @@ export const LayersPanel = memo(function LayersPanel() {
271
291
  onPointerUp={handleContainerPointerUp}
272
292
  onPointerCancel={handleContainerPointerUp}
273
293
  >
294
+ {activeGroupElement && (
295
+ <button
296
+ type="button"
297
+ onClick={() => setActiveGroupElement(null)}
298
+ className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
299
+ >
300
+ <span aria-hidden="true">←</span>
301
+ <span className="truncate">
302
+ {activeGroupElement.getAttribute("data-hf-group") || "Group"}
303
+ </span>
304
+ </button>
305
+ )}
274
306
  {visibleLayers.map((layer, index) => {
275
307
  const selected = layer.key === selectedKey;
276
308
  const isDragged = layer.key === dragKey;
@@ -286,6 +318,7 @@ export const LayersPanel = memo(function LayersPanel() {
286
318
  role="button"
287
319
  tabIndex={0}
288
320
  onClick={() => !dragKey && handleSelectLayer(layer)}
321
+ onDoubleClick={() => !dragKey && handleLayerDoubleClick(layer)}
289
322
  onPointerDown={(e) => handleRowPointerDown(index, e)}
290
323
  onPointerEnter={() => !dragKey && handleLayerHover(layer)}
291
324
  onKeyDown={(e) => {
@@ -21,6 +21,7 @@ import {
21
21
  elementHome,
22
22
  hasMotionPathPlugin,
23
23
  isPreviewHtmlElement,
24
+ transformWDivisor,
24
25
  useMotionPathData,
25
26
  } from "./useMotionPathData";
26
27
 
@@ -39,6 +40,7 @@ type DragState = {
39
40
  initX: number;
40
41
  initY: number;
41
42
  scale: number;
43
+ pScale: number;
42
44
  ref: MotionNodeRef;
43
45
  };
44
46
 
@@ -71,7 +73,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
71
73
  handleGsapRemoveKeyframe,
72
74
  handleGsapDeleteAllForElement,
73
75
  } = useDomEditContext();
74
- const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData(
76
+ const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
75
77
  iframeRef,
76
78
  selectorFor(selection),
77
79
  );
@@ -156,8 +158,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
156
158
  e.preventDefault();
157
159
  const sc = r.width / compW;
158
160
  const elHome = elementHome(live);
159
- const px = Math.round((e.clientX - r.left) / sc - elHome.x);
160
- const py = Math.round((e.clientY - r.top) / sc - elHome.y);
161
+ // De-magnify: the click lands on the projected (1/m44-magnified) path, so
162
+ // divide the home-relative offset by the perspective factor to recover the
163
+ // stored composition offset (inverse of the `* pScale` applied at draw).
164
+ const ps = 1 / transformWDivisor(live);
165
+ const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps);
166
+ const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps);
161
167
  const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
162
168
  void commitCreatePath(createSelector, t, px, py, commitMutation);
163
169
  setMotionPathArmed(false);
@@ -232,7 +238,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
232
238
  : geometry.nodes;
233
239
  // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
234
240
  // stay offsets so the drag commit writes the right tween values.
235
- const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
241
+ // Magnify the animated offsets by the element's perspective factor (1/m44, via
242
+ // pScale) so the path tracks the *projected* element. `home` is the projection
243
+ // pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
244
+ // elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
245
+ // pointer position is mapped back to a stored offset (create + node drag).
246
+ const abs = nodes.map((n) => ({
247
+ ...n,
248
+ ax: home.x + n.x * pScale,
249
+ ay: home.y + n.y * pScale,
250
+ }));
236
251
  const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
237
252
  // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
238
253
  // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
@@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
264
279
  initX: x,
265
280
  initY: y,
266
281
  scale,
282
+ pScale,
267
283
  ref,
268
284
  };
269
285
  setDraft({ index, x, y });
@@ -273,8 +289,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
273
289
  if (!d) return;
274
290
  setDraft({
275
291
  index: d.index,
276
- x: d.initX + (e.clientX - d.startX) / d.scale,
277
- y: d.initY + (e.clientY - d.startY) / d.scale,
292
+ x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale,
293
+ y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale,
278
294
  });
279
295
  };
280
296
  // fallow-ignore-next-line complexity
@@ -286,8 +302,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
286
302
  if (!animId) return;
287
303
  const screenDx = e.clientX - d.startX;
288
304
  const screenDy = e.clientY - d.startY;
289
- const x = Math.round(d.initX + screenDx / d.scale);
290
- const y = Math.round(d.initY + screenDy / d.scale);
305
+ const x = Math.round(d.initX + screenDx / d.scale / d.pScale);
306
+ const y = Math.round(d.initY + screenDy / d.scale / d.pScale);
291
307
  // Click-vs-drag is decided in SCREEN space, not composition px: the old guard
292
308
  // compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
293
309
  // multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would
@@ -1,5 +1,6 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState } from "react";
2
- import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
2
+ import { Eye, Layers, Move } from "../../icons/SystemIcons";
3
+ import { InspectorHeaderActions } from "./InspectorHeaderActions";
3
4
  import { useStudioShellContext } from "../../contexts/StudioContext";
4
5
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
5
6
  import {
@@ -53,6 +54,7 @@ export const PropertyPanel = memo(function PropertyPanel({
53
54
  multiSelectCount = 0,
54
55
  copiedAgentPrompt: _copiedAgentPrompt,
55
56
  onClearSelection,
57
+ onUngroup,
56
58
  onSetStyle,
57
59
  onSetAttribute,
58
60
  onSetAttributeLive,
@@ -146,6 +148,19 @@ export const PropertyPanel = memo(function PropertyPanel({
146
148
  // eslint-disable-next-line react-hooks/exhaustive-deps
147
149
  [gsapRuntimeValues, gsapAnimations, element, currentTime],
148
150
  );
151
+ // The 3D Transform panel should be reachable on ANY element, not only ones GSAP is
152
+ // already animating — otherwise you can't add depth/rotation to a fresh static
153
+ // element (the panel never appears, the classic chicken-and-egg). Default to
154
+ // identity when there are no runtime values yet; the first edit creates the
155
+ // gsap.set via commitStaticSet, after which real runtime values flow in.
156
+ const gsap3dValues: Record<string, number> = gsapRuntimeValues ?? {
157
+ rotationX: 0,
158
+ rotationY: 0,
159
+ rotationZ: 0,
160
+ z: 0,
161
+ scale: 1,
162
+ transformPerspective: 0,
163
+ };
149
164
 
150
165
  if (!element) {
151
166
  return (
@@ -295,38 +310,13 @@ export const PropertyPanel = memo(function PropertyPanel({
295
310
  </div>
296
311
  <div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
297
312
  </div>
298
- <div className="flex items-center gap-1">
299
- <button
300
- type="button"
301
- onClick={handleCopyElementInfo}
302
- className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
303
- clipboardCopied
304
- ? "text-studio-accent"
305
- : "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
306
- }`}
307
- title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
308
- >
309
- <svg
310
- width="13"
311
- height="13"
312
- viewBox="0 0 16 16"
313
- fill="none"
314
- stroke="currentColor"
315
- strokeWidth="1.5"
316
- >
317
- <rect x="5" y="5" width="9" height="9" rx="1.5" />
318
- <path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
319
- </svg>
320
- </button>
321
- <button
322
- type="button"
323
- aria-label="Clear selection"
324
- onClick={onClearSelection}
325
- className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
326
- >
327
- <X size={13} />
328
- </button>
329
- </div>
313
+ <InspectorHeaderActions
314
+ element={element}
315
+ copied={clipboardCopied}
316
+ onCopy={handleCopyElementInfo}
317
+ onClear={onClearSelection}
318
+ onUngroup={onUngroup}
319
+ />
330
320
  </div>
331
321
  </div>
332
322
  <div className="flex-1 overflow-y-auto">
@@ -513,33 +503,31 @@ export const PropertyPanel = memo(function PropertyPanel({
513
503
  )}
514
504
  </div>
515
505
  </div>
516
- {gsapRuntimeValues && (
517
- <PropertyPanel3dTransform
518
- gsapRuntimeValues={gsapRuntimeValues}
519
- gsapAnimId={gsapAnimId}
520
- resolveAnimIdForProp={animIdForProp}
521
- gsapKeyframes={navKeyframes}
522
- currentPct={currentPct}
523
- elStart={elStart}
524
- elDuration={elDuration}
525
- element={element}
526
- onCommitAnimatedProperty={onCommitAnimatedProperty}
527
- onCommitAnimatedProperties={onCommitAnimatedProperties}
528
- onSeekToTime={onSeekToTime}
529
- onRemoveKeyframe={onRemoveKeyframe}
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
- }}
541
- />
542
- )}
506
+ <PropertyPanel3dTransform
507
+ gsapRuntimeValues={gsap3dValues}
508
+ gsapAnimId={gsapAnimId}
509
+ resolveAnimIdForProp={animIdForProp}
510
+ gsapKeyframes={navKeyframes}
511
+ currentPct={currentPct}
512
+ elStart={elStart}
513
+ elDuration={elDuration}
514
+ element={element}
515
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
516
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
517
+ onSeekToTime={onSeekToTime}
518
+ onRemoveKeyframe={onRemoveKeyframe}
519
+ onConvertToKeyframes={onConvertToKeyframes}
520
+ onLivePreviewProps={(el, props) => {
521
+ const iframe = iframeRef.current;
522
+ const win = iframe?.contentWindow as
523
+ | { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
524
+ | null
525
+ | undefined;
526
+ const sel = el.id ? `#${el.id}` : el.selector;
527
+ const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
528
+ if (win?.gsap && node) win.gsap.set(node, props);
529
+ }}
530
+ />
543
531
  <div className="mt-3">
544
532
  <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
545
533
  Stacking