@hyperframes/studio 0.7.6 → 0.7.8

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 (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. package/src/utils/editDebugLog.ts +0 -9
@@ -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
  })}
@@ -85,10 +85,12 @@ export const PropertyPanel = memo(function PropertyPanel({
85
85
  onUpdateArcSegment,
86
86
  onUnroll,
87
87
  onUpdateKeyframeEase,
88
+ onSetAllKeyframeEases,
88
89
  onAddKeyframe,
89
90
  onRemoveKeyframe,
90
91
  onConvertToKeyframes,
91
92
  onCommitAnimatedProperty,
93
+ onCommitAnimatedProperties,
92
94
  onSeekToTime,
93
95
  recordingState,
94
96
  recordingDuration,
@@ -347,8 +349,15 @@ export const PropertyPanel = memo(function PropertyPanel({
347
349
  onRemoveTextField={onRemoveTextField}
348
350
  />
349
351
 
350
- {element.dataAttributes.start != null && (
351
- <TimingSection element={element} onSetAttribute={onSetAttribute} />
352
+ {(element.dataAttributes.start != null || gsapAnimations.length > 0) && (
353
+ // Render whenever there's an authored clip range OR animations to infer
354
+ // one from — a pure-GSAP element with no data-start still gets a Timing
355
+ // range (TimingSection derives it from its tweens).
356
+ <TimingSection
357
+ element={element}
358
+ animations={gsapAnimations}
359
+ onSetAttribute={onSetAttribute}
360
+ />
352
361
  )}
353
362
  {isMediaElement(element) && (
354
363
  <MediaSection
@@ -515,9 +524,20 @@ export const PropertyPanel = memo(function PropertyPanel({
515
524
  elDuration={elDuration}
516
525
  element={element}
517
526
  onCommitAnimatedProperty={onCommitAnimatedProperty}
527
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
518
528
  onSeekToTime={onSeekToTime}
519
529
  onRemoveKeyframe={onRemoveKeyframe}
520
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
+ }}
521
541
  />
522
542
  )}
523
543
  <div className="mt-3">
@@ -556,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel({
556
576
  onUpdateArcSegment={onUpdateArcSegment}
557
577
  onUnroll={onUnroll}
558
578
  onUpdateKeyframeEase={onUpdateKeyframeEase}
579
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
559
580
  />
560
581
  )}
561
582
 
@@ -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
+ }
@@ -29,6 +29,8 @@ export interface GsapAnimationEditCallbacks {
29
29
  update: Partial<ArcPathSegment>,
30
30
  ) => void;
31
31
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
32
+ /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
33
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
32
34
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
33
35
  onUnroll?: (animationId: string) => void;
34
36
  }
@@ -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",
@@ -88,41 +92,12 @@ export const PROP_TOOLTIPS: Record<string, string> = {
88
92
  innerText: "End value for a number roll-up (the number it counts up/down to)",
89
93
  };
90
94
 
91
- export const EASE_LABELS: Record<string, string> = {
92
- none: "Constant speed",
93
- "power1.out": "Gentle slowdown",
94
- "power2.out": "Smooth slowdown",
95
- "power3.out": "Snappy slowdown",
96
- "power4.out": "Sharp slowdown",
97
- "power1.in": "Gentle speedup",
98
- "power2.in": "Smooth speedup",
99
- "power3.in": "Strong speedup",
100
- "power4.in": "Sharp speedup",
101
- "power1.inOut": "Gentle ease",
102
- "power2.inOut": "Smooth ease",
103
- "power3.inOut": "Strong ease",
104
- "power4.inOut": "Sharp ease",
105
- "back.out": "Overshoot & settle",
106
- "back.in": "Pull back & go",
107
- "back.inOut": "Pull & overshoot",
108
- "elastic.out": "Springy bounce",
109
- "elastic.in": "Wind up spring",
110
- "elastic.inOut": "Full spring",
111
- "bounce.out": "Drop & bounce",
112
- "bounce.in": "Reverse bounce",
113
- "bounce.inOut": "Double bounce",
114
- "expo.out": "Very snappy stop",
115
- "expo.in": "Very slow start",
116
- "expo.inOut": "Dramatic ease",
117
- "spring-gentle": "Gentle spring",
118
- "spring-bouncy": "Bouncy spring",
119
- "spring-stiff": "Stiff spring",
120
- "spring-wobbly": "Wobbly spring",
121
- "spring-heavy": "Heavy spring",
122
- "ae-ease": "Easy Ease (AE)",
123
- "ae-ease-in": "Easy Ease In (AE)",
124
- "ae-ease-out": "Easy Ease Out (AE)",
125
- };
95
+ // Ease labels surface the raw GSAP token (e.g. "power2.out", "back.out") rather
96
+ // than friendly names — motion authors recognize the GSAP vocabulary, and the
97
+ // invented labels ("Smooth speedup") confused users. Every consumer reads
98
+ // `EASE_LABELS[token] ?? token`, so an empty map cleanly falls through to the
99
+ // token; re-add an entry here only to override a specific token's display.
100
+ export const EASE_LABELS: Record<string, string> = {};
126
101
 
127
102
  export const EASE_CURVES: Record<string, [number, number, number, number]> = {
128
103
  none: [0, 0, 1, 1],
@@ -179,6 +154,7 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
179
154
  rotationY: { step: 1 },
180
155
  rotationZ: { step: 1 },
181
156
  perspective: { min: 0, step: 1 },
157
+ transformPerspective: { min: 0, step: 1 },
182
158
  skewX: { min: -90, max: 90, step: 1 },
183
159
  skewY: { min: -90, max: 90, step: 1 },
184
160
  width: { min: 0, step: 1 },
@@ -73,7 +73,7 @@ describe("buildTweenSummary", () => {
73
73
  expect(s).toContain("[opacity 0%");
74
74
  expect(s).toContain("move x -50px");
75
75
  expect(s).toContain("opacity to 100%");
76
- expect(s).toContain("very snappy stop");
76
+ expect(s).toContain("expo.out");
77
77
  });
78
78
 
79
79
  it("handles fromTo with empty fromProperties", () => {
@@ -3,7 +3,8 @@ import type { DomEditSelection } from "./domEditing";
3
3
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4
4
  import { isElementComputedVisible } from "./domEditingElement";
5
5
  import { coversComposition } from "../../utils/studioPreviewHelpers";
6
- import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
6
+ import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
7
+ import { toOverlayRect } from "./domEditOverlayGeometry";
7
8
 
8
9
  interface MarqueeState {
9
10
  startX: number;
@@ -16,13 +17,26 @@ interface MarqueeState {
16
17
 
17
18
  const MARQUEE_THRESHOLD_PX = 4;
18
19
 
20
+ interface MarqueeHit {
21
+ element: HTMLElement;
22
+ rect: Rect;
23
+ }
24
+
25
+ /**
26
+ * Synchronous core of the marquee: the elements whose overlay-space rect
27
+ * intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
28
+ * single-selection / group-selection boxes, so what the marquee highlights
29
+ * and selects is exactly the box the user sees when they click an element.
30
+ * Shared by the live candidate highlight (per pointer-move) and the mouse-up
31
+ * commit. No async source probe — that only happens once, on commit.
32
+ */
19
33
  // fallow-ignore-next-line complexity
20
- async function runMarqueeIntersection(
21
- rect: { left: number; top: number; width: number; height: number },
34
+ function collectMarqueeHits(
35
+ rect: Rect,
22
36
  iframe: HTMLIFrameElement,
23
37
  overlayEl: HTMLDivElement,
24
38
  activeCompositionPath: string,
25
- ): Promise<DomEditSelection[]> {
39
+ ): MarqueeHit[] {
26
40
  const doc = iframe.contentDocument;
27
41
  if (!doc) return [];
28
42
 
@@ -38,22 +52,42 @@ async function runMarqueeIntersection(
38
52
  height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
39
53
  };
40
54
 
41
- const hits: DomEditSelection[] = [];
55
+ const hits: MarqueeHit[] = [];
42
56
  for (const item of items) {
43
57
  const el = item.element;
44
58
  if (!isElementComputedVisible(el)) continue;
45
59
  if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
46
- const corners = elementObbCorners(el, overlayEl, iframe);
47
- if (!corners) continue;
48
- if (!marqueeIntersectsObb(rect, corners)) continue;
49
- const sel = await resolveDomEditSelection(el, {
60
+ const overlayRect = toOverlayRect(overlayEl, iframe, el);
61
+ if (!overlayRect) continue;
62
+ const r: Rect = {
63
+ left: overlayRect.left,
64
+ top: overlayRect.top,
65
+ width: overlayRect.width,
66
+ height: overlayRect.height,
67
+ };
68
+ if (!rectsOverlap(rect, r)) continue;
69
+ hits.push({ element: el, rect: r });
70
+ }
71
+
72
+ return hits;
73
+ }
74
+
75
+ async function runMarqueeIntersection(
76
+ rect: Rect,
77
+ iframe: HTMLIFrameElement,
78
+ overlayEl: HTMLDivElement,
79
+ activeCompositionPath: string,
80
+ ): Promise<DomEditSelection[]> {
81
+ const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
82
+ const hits: DomEditSelection[] = [];
83
+ for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
84
+ const sel = await resolveDomEditSelection(element, {
50
85
  activeCompositionPath,
51
86
  isMasterView,
52
87
  skipSourceProbe: true,
53
88
  });
54
89
  if (sel) hits.push(sel);
55
90
  }
56
-
57
91
  return hits;
58
92
  }
59
93
 
@@ -75,12 +109,12 @@ interface MarqueeGesturesDeps {
75
109
  // fallow-ignore-next-line complexity
76
110
  export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
77
111
  const marqueeRef = useRef<MarqueeState | null>(null);
78
- const [marqueeRect, setMarqueeRect] = useState<{
79
- left: number;
80
- top: number;
81
- width: number;
82
- height: number;
83
- } | null>(null);
112
+ const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
113
+ // Live "candidate" highlight: the elements the marquee currently touches,
114
+ // shown before mouse-up so you can see what you're about to select. The
115
+ // iframe DOM doesn't mutate during a drag, so a sync intersection per move
116
+ // is cheap (clean layout → no thrash).
117
+ const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
84
118
 
85
119
  const commitMarquee = useCallback(
86
120
  async (
@@ -111,17 +145,24 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
111
145
  if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
112
146
  m.pastThreshold = true;
113
147
  }
114
- setMarqueeRect({
148
+ const rect: Rect = {
115
149
  left: Math.min(m.startX, m.currentX),
116
150
  top: Math.min(m.startY, m.currentY),
117
151
  width: Math.abs(m.currentX - m.startX),
118
152
  height: Math.abs(m.currentY - m.startY),
119
- });
153
+ };
154
+ setMarqueeRect(rect);
155
+ const iframe = deps.iframeRef.current;
156
+ const overlay = deps.overlayRef.current;
157
+ if (iframe && overlay) {
158
+ const acp = deps.activeCompositionPathRef.current ?? "index.html";
159
+ setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
160
+ }
120
161
  return;
121
162
  }
122
163
  deps.gestures.onPointerMove(event);
123
164
  },
124
- [deps.gestures, deps.overlayRef],
165
+ [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
125
166
  );
126
167
 
127
168
  const onPointerUp = useCallback(
@@ -148,6 +189,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
148
189
  deps.onMarqueeSelectRef.current?.([], false);
149
190
  }
150
191
  setMarqueeRect(null);
192
+ setCandidateRects([]);
151
193
  return;
152
194
  }
153
195
  deps.gestures.onPointerUp(event);
@@ -159,10 +201,11 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
159
201
  if (marqueeRef.current) {
160
202
  marqueeRef.current = null;
161
203
  setMarqueeRect(null);
204
+ setCandidateRects([]);
162
205
  return;
163
206
  }
164
207
  deps.gestures.clearPointerState(deps.selectionRef);
165
208
  }, [deps.gestures, deps.selectionRef]);
166
209
 
167
- return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
210
+ return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
168
211
  }