@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
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
3
3
 
4
4
  export interface CubePose {
@@ -22,80 +22,19 @@ const SENSITIVITY = 0.6; // degrees per pixel of drag
22
22
  * Presentational only: emits a live draft pose while dragging and a final pose
23
23
  * on release — the parent owns live-previewing and committing to GSAP props.
24
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));
25
+ // transformPerspective (px) drives the cube's weak-perspective projection;
26
+ // 0 = off flattest (largest projection distance).
35
27
  const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
36
28
 
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
29
  export function Transform3DCube({
93
30
  pose,
94
31
  perspective = 0,
32
+ defaultPerspective = 0,
33
+ z = 0,
95
34
  onPoseDraft,
96
35
  onPoseCommit,
97
- onPerspectiveDraft,
98
- onPerspectiveCommit,
36
+ onDepthDraft,
37
+ onDepthCommit,
99
38
  onRecenter,
100
39
  onKeyframe,
101
40
  keyframed,
@@ -103,13 +42,18 @@ export function Transform3DCube({
103
42
  pose: CubePose;
104
43
  /** Element's transformPerspective (px); drives the cube's foreshortening. */
105
44
  perspective?: number;
45
+ /** Comp-derived lens used for depth feedback before a perspective is committed. */
46
+ defaultPerspective?: number;
47
+ /** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
48
+ z?: number;
106
49
  /** Fires on every drag move with the in-progress pose (parent live-previews). */
107
50
  onPoseDraft?: (pose: CubePose) => void;
108
51
  /** Fires once on pointer release with the final pose (commit). */
109
52
  onPoseCommit: (pose: CubePose) => void;
110
- /** Live + committed perspective (px) from the in-cube slider. */
111
- onPerspectiveDraft?: (px: number) => void;
112
- onPerspectiveCommit?: (px: number) => void;
53
+ /** Live depth (translateZ px) during a scroll; parent live-previews it. */
54
+ onDepthDraft?: (z: number) => void;
55
+ /** Committed depth (translateZ px) once a scroll burst settles. */
56
+ onDepthCommit?: (z: number) => void;
113
57
  /** Reset to identity orientation. */
114
58
  onRecenter?: () => void;
115
59
  /** Toggle keyframing the 3D transform (convert the static set → keyframes). */
@@ -118,16 +62,74 @@ export function Transform3DCube({
118
62
  keyframed?: boolean;
119
63
  }) {
120
64
  const [draft, setDraft] = useState<CubePose | null>(null);
65
+ const [depthDraft, setDepthDraft] = useState<number | null>(null);
121
66
  const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
122
67
  const shown = draft ?? pose;
68
+ const shownZ = depthDraft ?? z;
69
+
70
+ // Scroll over the cube to push the element along Z (depth) — matches the
71
+ // studio's "scroll = z depth" gesture-recording convention. A non-passive
72
+ // listener is required so preventDefault can stop the panel from scrolling.
73
+ const svgRef = useRef<SVGSVGElement | null>(null);
74
+ // Perspective lens (committed, else the comp-derived default the panel will
75
+ // apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth
76
+ // can't cross the lens. Defined here so the wheel handler can read it via the ref.
77
+ const lens = perspective > 0 ? perspective : defaultPerspective;
78
+ const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens });
79
+ depthRef.current = { z, onDepthDraft, onDepthCommit, lens };
80
+ useEffect(() => {
81
+ const el = svgRef.current;
82
+ if (!el) return;
83
+ let pending: number | null = null;
84
+ let timer: ReturnType<typeof setTimeout> | null = null;
85
+ const onWheel = (e: WheelEvent) => {
86
+ const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current;
87
+ if (!commit) return;
88
+ e.preventDefault();
89
+ // ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
90
+ // it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
91
+ let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
92
+ // Clamp depth in front of the perspective lens. At z ≥ lens the element sits
93
+ // at/behind the virtual camera and the projection lens/(lens−z) blows up or
94
+ // inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short
95
+ // of the lens; allow pushing well back (smaller) but not absurdly far.
96
+ const L = depthRef.current.lens;
97
+ if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4));
98
+ pending = next;
99
+ draft?.(pending);
100
+ setDepthDraft(pending); // live-scale the cube while scrolling
101
+ if (timer) clearTimeout(timer);
102
+ timer = setTimeout(() => {
103
+ if (pending != null) commit(pending);
104
+ pending = null;
105
+ setDepthDraft(null); // fall back to the committed z prop
106
+ }, 160);
107
+ };
108
+ el.addEventListener("wheel", onWheel, { passive: false });
109
+ return () => {
110
+ el.removeEventListener("wheel", onWheel);
111
+ if (timer) clearTimeout(timer);
112
+ };
113
+ }, []);
114
+
115
+ // Depth feedback: the cube scales like the element would — translateZ(z) under
116
+ // a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
117
+ // farther (z<0) smaller. Use the committed perspective, else the comp-derived
118
+ // lens the panel is about to apply — same value in both, so the cube doesn't
119
+ // jump when the commit lands. If neither is known, skip the scale (no lens).
120
+ const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1;
123
121
  const projOpts = {
124
122
  cx: CX,
125
123
  cy: CY,
126
- r: RADIUS,
127
- persp: pxToProjPersp(perspective),
124
+ r: RADIUS * depthScale,
125
+ persp: pxToProjPersp(lens),
128
126
  };
129
- const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
130
- const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
127
+ // The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
128
+ // and RotateZ act in planes that contain Y, so they read inverted in the gizmo
129
+ // unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the
130
+ // cube's orientation a true mirror of the element.
131
+ const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
132
+ const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
131
133
 
132
134
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
133
135
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -140,10 +142,13 @@ export function Transform3DCube({
140
142
  if (!d) return;
141
143
  const dx = e.clientX - d.x;
142
144
  const dy = e.clientY - d.y;
145
+ // dy→rotationX and shift dx→rotationZ are negated to match the projection's
146
+ // sign flip (above), so the cube's response to a drag is unchanged while the
147
+ // element now rotates in lock-step with it.
143
148
  const next: CubePose = e.shiftKey
144
- ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) }
149
+ ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) }
145
150
  : {
146
- rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY),
151
+ rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY),
147
152
  rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
148
153
  rotationZ: d.pose.rotationZ,
149
154
  };
@@ -161,6 +166,7 @@ export function Transform3DCube({
161
166
  return (
162
167
  <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
163
168
  <svg
169
+ ref={svgRef}
164
170
  viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
165
171
  className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
166
172
  style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
@@ -169,7 +175,7 @@ export function Transform3DCube({
169
175
  onPointerUp={onPointerUp}
170
176
  onPointerCancel={onPointerUp}
171
177
  role="slider"
172
- aria-label="Drag to rotate in 3D; hold Shift to roll"
178
+ aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
173
179
  aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
174
180
  shown.rotationY,
175
181
  )}°, Z ${Math.round(shown.rotationZ)}°`}
@@ -301,13 +307,6 @@ export function Transform3DCube({
301
307
  </svg>
302
308
  </button>
303
309
  )}
304
- {onPerspectiveCommit && (
305
- <PerspectiveSlider
306
- value={perspective}
307
- onDraft={onPerspectiveDraft}
308
- onCommit={onPerspectiveCommit}
309
- />
310
- )}
311
310
  </div>
312
311
  );
313
312
  }
@@ -186,6 +186,34 @@ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRec
186
186
  };
187
187
  }
188
188
 
189
+ // A group's overlay box encompasses its members' actual rendered bounds, not just
190
+ // the wrapper's own box — so members moved or transformed out of the wrapper still
191
+ // sit inside the box. Used by the selection, hover, and off-canvas overlays so they
192
+ // all agree on where a group is.
193
+ export function groupAwareOverlayRect(
194
+ overlayEl: HTMLDivElement,
195
+ iframe: HTMLIFrameElement,
196
+ el: HTMLElement,
197
+ ): OverlayRect | null {
198
+ const rect = toOverlayRect(overlayEl, iframe, el);
199
+ if (!rect || !el.hasAttribute("data-hf-group")) return rect;
200
+ // Union the MEMBERS' rendered rects — where the content actually is — not the
201
+ // wrapper's own box. The wrapper is invisible and its box can sit apart from the
202
+ // members once they've been moved/transformed, which would otherwise drag the
203
+ // group's bounds (and its off-canvas marker) off to a stale position.
204
+ const rects: OverlayRect[] = [];
205
+ for (const child of Array.from(el.children)) {
206
+ const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
207
+ if (childRect) rects.push(childRect);
208
+ }
209
+ const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
210
+ if (!union) return rect; // empty group → fall back to the wrapper box
211
+ // resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's
212
+ // real edit (display) scale, which the drag uses to convert pointer→offset — a
213
+ // reset-to-1 makes the group move at ~display-scale speed and lag the cursor.
214
+ return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
215
+ }
216
+
189
217
  export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
190
218
  return items.filter(
191
219
  (item) => !items.some((other) => other !== item && other.element.contains(item.element)),
@@ -250,7 +250,7 @@ export function querySelectorAllSafely(doc: Document, selector: string): Element
250
250
  }
251
251
  }
252
252
 
253
- export function humanizeIdentifier(value: string): string {
253
+ function humanizeIdentifier(value: string): string {
254
254
  return (
255
255
  value
256
256
  .replace(/\.html$/i, "")
@@ -270,10 +270,16 @@ export function buildStableSelector(el: HTMLElement): string | undefined {
270
270
  const compositionId = el.getAttribute("data-composition-id");
271
271
  if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
272
272
 
273
+ // Group wrappers carry no id/class; their data-hf-group value is the unique,
274
+ // stable handle the source mutations write — use it so the wrapper is
275
+ // selectable, patchable (move/scale), and addressable for ungroup.
276
+ const group = el.getAttribute("data-hf-group");
277
+ if (group) return `[data-hf-group="${escapeCssString(group)}"]`;
278
+
273
279
  return getPreferredClassSelector(el);
274
280
  }
275
281
 
276
- export function getPreferredClassSelector(el: HTMLElement): string | undefined {
282
+ function getPreferredClassSelector(el: HTMLElement): string | undefined {
277
283
  const classes = Array.from(el.classList)
278
284
  .map((value) => value.trim())
279
285
  .filter(Boolean);
@@ -283,6 +289,34 @@ export function getPreferredClassSelector(el: HTMLElement): string | undefined {
283
289
  return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
284
290
  }
285
291
 
292
+ // fallow-ignore-next-line complexity
293
+ export function buildElementLabel(el: HTMLElement): string {
294
+ const compositionId = el.getAttribute("data-composition-id");
295
+ if (compositionId && compositionId !== "main") {
296
+ return humanizeIdentifier(compositionId);
297
+ }
298
+
299
+ const compositionSrc =
300
+ el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
301
+ if (compositionSrc) {
302
+ return humanizeIdentifier(compositionSrc);
303
+ }
304
+
305
+ const group = el.getAttribute("data-hf-group");
306
+ if (group) return group;
307
+
308
+ if (el.id) return humanizeIdentifier(el.id);
309
+
310
+ const preferredClass = getPreferredClassSelector(el);
311
+ if (preferredClass) {
312
+ return humanizeIdentifier(preferredClass.replace(/^\./, ""));
313
+ }
314
+
315
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
316
+ if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
317
+ return el.tagName.toLowerCase();
318
+ }
319
+
286
320
  export function getSelectorIndex(
287
321
  doc: Document,
288
322
  el: HTMLElement,
@@ -0,0 +1,41 @@
1
+ import { isHtmlElement } from "./domEditingDom";
2
+
3
+ // `data-hf-group` selection semantics: a group wrapper is selected as one unit
4
+ // until the user drills into it; once drilled in, clicks resolve to its children
5
+ // (or to the next nested group inside it). One level of drill-in at a time keeps
6
+ // nested groups navigable.
7
+
8
+ export type GroupCapture =
9
+ | { kind: "unit"; element: HTMLElement } // select this group wrapper as one unit
10
+ | { kind: "child" } // resolve the clicked element normally
11
+ | { kind: "out-of-scope" }; // clicked outside the drilled-into group → select nothing
12
+
13
+ // Layer-tree roots: the drilled-into group's element children, else the doc root.
14
+ export function groupScopedLayerRoots(
15
+ root: HTMLElement,
16
+ activeGroupElement: HTMLElement | null,
17
+ ): HTMLElement[] {
18
+ const els = activeGroupElement?.isConnected ? Array.from(activeGroupElement.children) : [root];
19
+ return els.filter(isHtmlElement);
20
+ }
21
+
22
+ export function resolveGroupCapture(
23
+ startEl: HTMLElement,
24
+ activeGroupElement: HTMLElement | null,
25
+ ): GroupCapture {
26
+ const groups: HTMLElement[] = [];
27
+ for (let n: HTMLElement | null = startEl; n; n = n.parentElement) {
28
+ if (n.hasAttribute("data-hf-group")) groups.push(n);
29
+ }
30
+ const result = ((): GroupCapture => {
31
+ if (!activeGroupElement) {
32
+ const outermost = groups[groups.length - 1];
33
+ return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
34
+ }
35
+ const idx = groups.indexOf(activeGroupElement);
36
+ if (idx === -1) return { kind: "out-of-scope" };
37
+ const nestedInside = groups[idx - 1];
38
+ return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
39
+ })();
40
+ return result;
41
+ }
@@ -1,6 +1,11 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
- import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
3
+ import {
4
+ collectDomEditLayerItems,
5
+ resolveDomEditSelection,
6
+ buildDomEditPatchTarget,
7
+ readHfId,
8
+ } from "./domEditingLayers";
4
9
 
5
10
  const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
6
11
 
@@ -76,3 +81,94 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
76
81
  expect(selection?.hfId).toBeUndefined();
77
82
  });
78
83
  });
84
+
85
+ describe("resolveDomEditSelection — data-hf-group capture", () => {
86
+ // <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
87
+ // <span id="child"/></div></div></div>
88
+ function buildNestedGroups() {
89
+ const parent = document.createElement("div");
90
+ parent.id = "parent";
91
+ const outer = document.createElement("div");
92
+ outer.setAttribute("data-hf-group", "Group 1");
93
+ const inner = document.createElement("div");
94
+ inner.setAttribute("data-hf-group", "Group 2");
95
+ const child = document.createElement("span");
96
+ child.id = "child";
97
+ inner.appendChild(child);
98
+ outer.appendChild(inner);
99
+ parent.appendChild(outer);
100
+ document.body.appendChild(parent);
101
+ return { parent, outer, inner, child };
102
+ }
103
+
104
+ it("selects the outermost group as a unit when clicking a child (not drilled in)", async () => {
105
+ const { parent, outer, child } = buildNestedGroups();
106
+ const selection = await resolveDomEditSelection(child, opts);
107
+ document.body.removeChild(parent);
108
+
109
+ expect(selection?.element).toBe(outer);
110
+ expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
111
+ });
112
+
113
+ it("selects the next nested group when drilled into the outer group", async () => {
114
+ const { parent, outer, inner, child } = buildNestedGroups();
115
+ const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });
116
+ document.body.removeChild(parent);
117
+
118
+ expect(selection?.element).toBe(inner);
119
+ expect(selection?.selector).toBe('[data-hf-group="Group 2"]');
120
+ });
121
+
122
+ it("selects the child when drilled all the way into the innermost group", async () => {
123
+ const { parent, inner, child } = buildNestedGroups();
124
+ const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: inner });
125
+ document.body.removeChild(parent);
126
+
127
+ expect(selection?.element).toBe(child);
128
+ expect(selection?.id).toBe("child");
129
+ });
130
+
131
+ it("layer tree is scoped to the group's members when drilled in", () => {
132
+ const root = document.createElement("div");
133
+ root.setAttribute("data-composition-id", "main");
134
+ const group = document.createElement("div");
135
+ group.setAttribute("data-hf-group", "Group 1");
136
+ const inside = document.createElement("div");
137
+ inside.id = "inside";
138
+ const outside = document.createElement("div");
139
+ outside.id = "outside";
140
+ group.appendChild(inside);
141
+ root.appendChild(group);
142
+ root.appendChild(outside);
143
+ document.body.appendChild(root);
144
+
145
+ const opts2 = { activeCompositionPath: "index.html", isMasterView: true };
146
+ const full = collectDomEditLayerItems(root, opts2).map((i) => i.id);
147
+ const scoped = collectDomEditLayerItems(root, { ...opts2, activeGroupElement: group }).map(
148
+ (i) => i.id,
149
+ );
150
+ document.body.removeChild(root);
151
+
152
+ expect(full).toContain("outside");
153
+ expect(scoped).toContain("inside");
154
+ expect(scoped).not.toContain("outside");
155
+ });
156
+
157
+ it("exits the drilled-into group and selects the outside element (non-sticky drill)", async () => {
158
+ const { parent, inner } = buildNestedGroups();
159
+ const outside = document.createElement("div");
160
+ outside.id = "outside";
161
+ document.body.appendChild(outside);
162
+
163
+ const selection = await resolveDomEditSelection(outside, {
164
+ ...opts,
165
+ activeGroupElement: inner,
166
+ });
167
+ document.body.removeChild(parent);
168
+ document.body.removeChild(outside);
169
+
170
+ // Drill-in is non-sticky: clicking outside the active group exits it and
171
+ // resolves the clicked element normally (rather than selecting nothing).
172
+ expect(selection?.id).toBe("outside");
173
+ });
174
+ });
@@ -3,6 +3,7 @@
3
3
  * for dom editing.
4
4
  */
5
5
  import type { PatchOperation } from "../../utils/sourcePatcher";
6
+ import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
6
7
  import type {
7
8
  DomEditCapabilities,
8
9
  DomEditContextOptions,
@@ -11,15 +12,14 @@ import type {
11
12
  DomEditTextField,
12
13
  } from "./domEditingTypes";
13
14
  import {
15
+ buildElementLabel,
14
16
  buildStableSelector,
15
17
  findClosestByAttribute,
16
18
  getCuratedComputedStyles,
17
19
  getDataAttributes,
18
20
  getInlineStyles,
19
- getPreferredClassSelector,
20
21
  getSelectorIndex,
21
22
  getSourceFileForElement,
22
- humanizeIdentifier,
23
23
  isHtmlElement,
24
24
  isIdentityTransform,
25
25
  isTextBearingTag,
@@ -275,31 +275,6 @@ export function resolveDomEditCapabilities(args: {
275
275
 
276
276
  // ─── Element label ────────────────────────────────────────────────────────────
277
277
 
278
- // fallow-ignore-next-line complexity
279
- export function buildElementLabel(el: HTMLElement): string {
280
- const compositionId = el.getAttribute("data-composition-id");
281
- if (compositionId && compositionId !== "main") {
282
- return humanizeIdentifier(compositionId);
283
- }
284
-
285
- const compositionSrc =
286
- el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
287
- if (compositionSrc) {
288
- return humanizeIdentifier(compositionSrc);
289
- }
290
-
291
- if (el.id) return humanizeIdentifier(el.id);
292
-
293
- const preferredClass = getPreferredClassSelector(el);
294
- if (preferredClass) {
295
- return humanizeIdentifier(preferredClass.replace(/^\./, ""));
296
- }
297
-
298
- const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
299
- if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
300
- return el.tagName.toLowerCase();
301
- }
302
-
303
278
  // ─── Source probe ────────────────────────────────────────────────────────────
304
279
 
305
280
  async function probeSourceElement(
@@ -334,7 +309,15 @@ export async function resolveDomEditSelection(
334
309
  if (!startEl) return null;
335
310
  const doc = startEl.ownerDocument;
336
311
 
337
- let current: HTMLElement | null = getSelectionCandidate(startEl, options);
312
+ let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
313
+ if (capture.kind === "out-of-scope") {
314
+ // Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
315
+ // exits it and resolves the target normally, rather than selecting nothing
316
+ // (which felt like "can't select anything" once you'd drilled in).
317
+ capture = resolveGroupCapture(startEl, null);
318
+ }
319
+ let current: HTMLElement | null =
320
+ capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options);
338
321
  while (current && current !== doc.body && current !== doc.documentElement) {
339
322
  const selector = buildStableSelector(current);
340
323
  const hfId = readHfId(current);
@@ -501,7 +484,8 @@ export function collectDomEditLayerItems(
501
484
  }
502
485
  };
503
486
 
504
- visit(root, 0);
487
+ // Drilled into a group → show only its members; otherwise the whole tree.
488
+ for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
505
489
  return items;
506
490
  }
507
491
 
@@ -1,5 +1,5 @@
1
1
  import type { PatchTarget } from "../../utils/sourcePatcher";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
3
3
 
4
4
  export const CURATED_STYLE_PROPERTIES = [
5
5
  "position",
@@ -108,6 +108,9 @@ export interface DomEditContextOptions {
108
108
  activeCompositionPath: string | null;
109
109
  isMasterView: boolean;
110
110
  preferClipAncestor?: boolean;
111
+ /** The group wrapper the user has drilled into (null = top level). Selection
112
+ * resolution treats groups as a unit unless drilled into one. */
113
+ activeGroupElement?: HTMLElement | null;
111
114
  }
112
115
 
113
116
  export interface DomEditViewport {
@@ -1,4 +1,4 @@
1
- import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
1
+ import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
2
 
3
3
  /**
4
4
  * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
2
+ import { SUPPORTED_PROPS } from "@hyperframes/parsers/gsap-constants";
3
3
  import { buildTweenSummary } from "./gsapAnimationHelpers";
4
4
  import { PROP_LABELS } from "./gsapAnimationConstants";
5
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
6
 
7
7
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
8
8
  return {
@@ -1,4 +1,4 @@
1
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
2
2
  import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
3
3
 
4
4
  function formatPropValue(prop: string, v: number | string): string {
@@ -5,7 +5,7 @@ export {
5
5
  STUDIO_WIDTH_PROP,
6
6
  STUDIO_HEIGHT_PROP,
7
7
  STUDIO_MANUAL_EDIT_GESTURE_ATTR,
8
- } from "@hyperframes/core/studio-api/draft-markers";
8
+ } from "@hyperframes/studio-server/draft-markers";
9
9
  export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
10
10
 
11
11
  /* ── Internal DOM attribute names ─────────────────────────────────── */