@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.
- package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
- package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3650 -2431
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +7 -1
- package/src/components/editor/AnimationCard.tsx +6 -0
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/EaseCurveSection.tsx +175 -99
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +67 -3
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +23 -2
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -35
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +46 -18
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +364 -266
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +129 -9
- package/src/hooks/gsapRuntimeBridge.ts +22 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +24 -2
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGestureCommit.ts +0 -4
- package/src/hooks/useGsapAwareEditing.ts +31 -5
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +125 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-wJZf6FK3.css +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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("
|
|
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 {
|
|
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
|
-
|
|
21
|
-
rect:
|
|
34
|
+
function collectMarqueeHits(
|
|
35
|
+
rect: Rect,
|
|
22
36
|
iframe: HTMLIFrameElement,
|
|
23
37
|
overlayEl: HTMLDivElement,
|
|
24
38
|
activeCompositionPath: string,
|
|
25
|
-
):
|
|
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:
|
|
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
|
|
47
|
-
if (!
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
}
|