@hyperframes/studio 0.7.7 → 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-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
- package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +2699 -2052
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +5 -1
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +12 -0
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationConstants.ts +5 -0
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +43 -21
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/sidebar/AssetsTab.tsx +23 -213
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +28 -9
- package/src/hooks/gsapRuntimeKeyframes.ts +50 -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 +4 -2
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGsapAwareEditing.ts +31 -1
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.ts +6 -4
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-BSkUuN8g.css +0 -1
|
@@ -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
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
3
|
import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
|
3
4
|
import { MetricField } from "./propertyPanelPrimitives";
|
|
4
5
|
import { KeyframeNavigation } from "./KeyframeNavigation";
|
|
5
6
|
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
7
|
+
import { Transform3DCube, type CubePose } from "./Transform3DCube";
|
|
6
8
|
|
|
7
9
|
type KeyframeEntry = Array<{
|
|
8
10
|
percentage: number;
|
|
@@ -24,9 +26,216 @@ interface PropertyPanel3dTransformProps {
|
|
|
24
26
|
property: string,
|
|
25
27
|
value: number,
|
|
26
28
|
) => Promise<void>;
|
|
29
|
+
/** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */
|
|
30
|
+
onCommitAnimatedProperties?: (
|
|
31
|
+
element: DomEditSelection,
|
|
32
|
+
props: Record<string, number | string>,
|
|
33
|
+
) => Promise<void>;
|
|
27
34
|
onSeekToTime?: (time: number) => void;
|
|
28
35
|
onRemoveKeyframe?: (animId: string, pct: number) => void;
|
|
29
|
-
onConvertToKeyframes?: (animId: string) => void;
|
|
36
|
+
onConvertToKeyframes?: (animId: string, duration?: number) => void;
|
|
37
|
+
/** Live-set props on the preview element during a cube drag (no source write). */
|
|
38
|
+
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type CommitAnimatedProperty = (
|
|
42
|
+
element: DomEditSelection,
|
|
43
|
+
property: string,
|
|
44
|
+
value: number,
|
|
45
|
+
) => Promise<void>;
|
|
46
|
+
|
|
47
|
+
/** The draggable cube + its commit/recenter/live-preview wiring. */
|
|
48
|
+
function Cube3dControl({
|
|
49
|
+
element,
|
|
50
|
+
gsapRuntimeValues,
|
|
51
|
+
onCommitAnimatedProperty,
|
|
52
|
+
onCommitAnimatedProperties,
|
|
53
|
+
onLivePreviewProps,
|
|
54
|
+
onKeyframe,
|
|
55
|
+
keyframed,
|
|
56
|
+
}: {
|
|
57
|
+
element: DomEditSelection;
|
|
58
|
+
gsapRuntimeValues: Record<string, number>;
|
|
59
|
+
onCommitAnimatedProperty: CommitAnimatedProperty;
|
|
60
|
+
onCommitAnimatedProperties?: (
|
|
61
|
+
element: DomEditSelection,
|
|
62
|
+
props: Record<string, number | string>,
|
|
63
|
+
) => Promise<void>;
|
|
64
|
+
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
|
|
65
|
+
onKeyframe?: () => void;
|
|
66
|
+
keyframed?: boolean;
|
|
67
|
+
}) {
|
|
68
|
+
const pose: CubePose = {
|
|
69
|
+
rotationX: gsapRuntimeValues.rotationX ?? 0,
|
|
70
|
+
rotationY: gsapRuntimeValues.rotationY ?? 0,
|
|
71
|
+
rotationZ: gsapRuntimeValues.rotationZ ?? 0,
|
|
72
|
+
};
|
|
73
|
+
// Commit only the rotation axes the drag actually changed (each rounded to a
|
|
74
|
+
// whole degree). Reuses the keyframe-aware animated-property commit, so a drag
|
|
75
|
+
// at the playhead writes/updates a keyframe just like the numeric fields.
|
|
76
|
+
const commitPose = (next: CubePose) => {
|
|
77
|
+
const changedProps: Record<string, number> = {};
|
|
78
|
+
for (const axis of ["rotationX", "rotationY", "rotationZ"] as const) {
|
|
79
|
+
const rounded = Math.round(next[axis]);
|
|
80
|
+
if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded;
|
|
81
|
+
}
|
|
82
|
+
const axes = Object.keys(changedProps);
|
|
83
|
+
if (axes.length === 0) return;
|
|
84
|
+
// ONE keyframe for the whole pose change — avoids per-axis commits racing into
|
|
85
|
+
// adjacent duplicate keyframes. Fall back to per-axis if no batched commit.
|
|
86
|
+
if (onCommitAnimatedProperties) {
|
|
87
|
+
void onCommitAnimatedProperties(element, changedProps);
|
|
88
|
+
} else {
|
|
89
|
+
for (const [axis, v] of Object.entries(changedProps))
|
|
90
|
+
onCommitAnimatedProperty(element, axis, v);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const recenter = () => {
|
|
94
|
+
// ONE commit for the whole reset — six per-axis commits meant six soft-reloads
|
|
95
|
+
// (six flashes) for a single click. Batch like commitPose does.
|
|
96
|
+
const identity = {
|
|
97
|
+
rotationX: 0,
|
|
98
|
+
rotationY: 0,
|
|
99
|
+
rotationZ: 0,
|
|
100
|
+
z: 0,
|
|
101
|
+
scale: 1,
|
|
102
|
+
transformPerspective: 0,
|
|
103
|
+
};
|
|
104
|
+
if (onCommitAnimatedProperties) {
|
|
105
|
+
void onCommitAnimatedProperties(element, identity);
|
|
106
|
+
} else {
|
|
107
|
+
for (const [prop, v] of Object.entries(identity))
|
|
108
|
+
void onCommitAnimatedProperty(element, prop, v);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
// Immediate element feedback while dragging — set the live transform without a
|
|
112
|
+
// source write; the release commits via onCommitAnimatedProperty.
|
|
113
|
+
const livePreview = (next: CubePose) =>
|
|
114
|
+
onLivePreviewProps?.(element, {
|
|
115
|
+
rotationX: next.rotationX,
|
|
116
|
+
rotationY: next.rotationY,
|
|
117
|
+
rotationZ: next.rotationZ,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="mb-2 px-2">
|
|
122
|
+
<div className="mx-auto max-w-[184px]">
|
|
123
|
+
<Transform3DCube
|
|
124
|
+
pose={pose}
|
|
125
|
+
perspective={gsapRuntimeValues.transformPerspective ?? 0}
|
|
126
|
+
onPoseDraft={livePreview}
|
|
127
|
+
onPoseCommit={commitPose}
|
|
128
|
+
onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
|
|
129
|
+
onPerspectiveCommit={(px) =>
|
|
130
|
+
void onCommitAnimatedProperty(element, "transformPerspective", px)
|
|
131
|
+
}
|
|
132
|
+
onRecenter={recenter}
|
|
133
|
+
onKeyframe={onKeyframe}
|
|
134
|
+
keyframed={keyframed}
|
|
135
|
+
/>
|
|
136
|
+
<p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
|
|
137
|
+
Drag to tilt · Shift-drag to roll
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface FieldCtx {
|
|
145
|
+
element: DomEditSelection;
|
|
146
|
+
gsapRuntimeValues: Record<string, number>;
|
|
147
|
+
gsapKeyframes: KeyframeEntry;
|
|
148
|
+
gsapAnimId: string | null;
|
|
149
|
+
currentPct: number;
|
|
150
|
+
elStart: number;
|
|
151
|
+
elDuration: number;
|
|
152
|
+
resolveAnimIdForProp?: (prop: string) => string | null;
|
|
153
|
+
onCommitAnimatedProperty?: (
|
|
154
|
+
element: DomEditSelection,
|
|
155
|
+
property: string,
|
|
156
|
+
value: number,
|
|
157
|
+
) => Promise<void>;
|
|
158
|
+
onSeekToTime?: (time: number) => void;
|
|
159
|
+
onRemoveKeyframe?: (animId: string, pct: number) => void;
|
|
160
|
+
onConvertToKeyframes?: (animId: string, duration?: number) => void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parseDeg = (s: string): number | null => {
|
|
164
|
+
const n = Number.parseFloat(s.replace("°", ""));
|
|
165
|
+
return Number.isFinite(n) ? n : null;
|
|
166
|
+
};
|
|
167
|
+
const parseScale = (s: string): number | null => {
|
|
168
|
+
const n = Number.parseFloat(s);
|
|
169
|
+
return Number.isFinite(n) ? n : null;
|
|
170
|
+
};
|
|
171
|
+
const parsePxNonNeg = (s: string): number | null => {
|
|
172
|
+
const v = parsePxMetricValue(s);
|
|
173
|
+
return v != null && v >= 0 ? v : null;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One 3D-transform field: a number/scrub input plus its keyframe diamond, so
|
|
178
|
+
* rotation / perspective / Z / scale can each be keyframed just like Layout's
|
|
179
|
+
* X / Y — the diamond was previously missing on the rotation + perspective rows.
|
|
180
|
+
*/
|
|
181
|
+
function Transform3dField({
|
|
182
|
+
label,
|
|
183
|
+
prop,
|
|
184
|
+
scrub,
|
|
185
|
+
format,
|
|
186
|
+
parse,
|
|
187
|
+
defaultValue,
|
|
188
|
+
ctx,
|
|
189
|
+
}: {
|
|
190
|
+
label: string;
|
|
191
|
+
prop: string;
|
|
192
|
+
scrub?: boolean;
|
|
193
|
+
format: (v: number) => string;
|
|
194
|
+
parse: (s: string) => number | null;
|
|
195
|
+
defaultValue: number;
|
|
196
|
+
ctx: FieldCtx;
|
|
197
|
+
}) {
|
|
198
|
+
const { gsapAnimId, onCommitAnimatedProperty } = ctx;
|
|
199
|
+
const idFor = (p: string) => ctx.resolveAnimIdForProp?.(p) ?? gsapAnimId;
|
|
200
|
+
const current = ctx.gsapRuntimeValues[prop] ?? defaultValue;
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex items-center gap-1">
|
|
203
|
+
<div className="flex-1">
|
|
204
|
+
<MetricField
|
|
205
|
+
label={label}
|
|
206
|
+
value={format(current)}
|
|
207
|
+
scrub={scrub}
|
|
208
|
+
onCommit={(next) => {
|
|
209
|
+
const v = parse(next);
|
|
210
|
+
if (v != null && onCommitAnimatedProperty) {
|
|
211
|
+
void onCommitAnimatedProperty(ctx.element, prop, v);
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
|
217
|
+
<KeyframeNavigation
|
|
218
|
+
property={prop}
|
|
219
|
+
keyframes={ctx.gsapKeyframes}
|
|
220
|
+
currentPercentage={ctx.currentPct}
|
|
221
|
+
onSeek={(pct) => ctx.onSeekToTime?.(ctx.elStart + (pct / 100) * ctx.elDuration)}
|
|
222
|
+
onAddKeyframe={() => {
|
|
223
|
+
if (onCommitAnimatedProperty) void onCommitAnimatedProperty(ctx.element, prop, current);
|
|
224
|
+
}}
|
|
225
|
+
onRemoveKeyframe={(pct) => {
|
|
226
|
+
const id = idFor(prop);
|
|
227
|
+
if (id) ctx.onRemoveKeyframe?.(id, pct);
|
|
228
|
+
}}
|
|
229
|
+
onConvertToKeyframes={() => {
|
|
230
|
+
const id = idFor(prop);
|
|
231
|
+
// Pass the element's clip duration so a converted static 3D `set`
|
|
232
|
+
// spans the whole clip (keyframes land in range at any playhead).
|
|
233
|
+
if (id) ctx.onConvertToKeyframes?.(id, ctx.elDuration);
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
30
239
|
}
|
|
31
240
|
|
|
32
241
|
export function PropertyPanel3dTransform({
|
|
@@ -39,110 +248,120 @@ export function PropertyPanel3dTransform({
|
|
|
39
248
|
elDuration,
|
|
40
249
|
element,
|
|
41
250
|
onCommitAnimatedProperty,
|
|
251
|
+
onCommitAnimatedProperties,
|
|
42
252
|
onSeekToTime,
|
|
43
253
|
onRemoveKeyframe,
|
|
44
254
|
onConvertToKeyframes,
|
|
255
|
+
onLivePreviewProps,
|
|
45
256
|
}: PropertyPanel3dTransformProps) {
|
|
46
|
-
|
|
257
|
+
// Expanded by default — the cube gizmo is the headline of this panel, so show
|
|
258
|
+
// it up front rather than hiding it behind a collapsed header.
|
|
259
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
260
|
+
const ctx: FieldCtx = {
|
|
261
|
+
element,
|
|
262
|
+
gsapRuntimeValues,
|
|
263
|
+
gsapKeyframes,
|
|
264
|
+
gsapAnimId,
|
|
265
|
+
currentPct,
|
|
266
|
+
elStart,
|
|
267
|
+
elDuration,
|
|
268
|
+
resolveAnimIdForProp,
|
|
269
|
+
onCommitAnimatedProperty,
|
|
270
|
+
onSeekToTime,
|
|
271
|
+
onRemoveKeyframe,
|
|
272
|
+
onConvertToKeyframes,
|
|
273
|
+
};
|
|
274
|
+
|
|
47
275
|
return (
|
|
48
276
|
<div className="mt-3 border-t border-neutral-800/40 pt-3">
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const id = idFor("z");
|
|
80
|
-
if (id) onRemoveKeyframe?.(id, pct);
|
|
81
|
-
}}
|
|
82
|
-
onConvertToKeyframes={() => {
|
|
83
|
-
const id = idFor("z");
|
|
84
|
-
if (id) onConvertToKeyframes?.(id);
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
280
|
+
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
|
|
281
|
+
>
|
|
282
|
+
<span>3D Transform</span>
|
|
283
|
+
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
|
|
284
|
+
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
|
|
285
|
+
</svg>
|
|
286
|
+
</button>
|
|
287
|
+
{collapsed ? null : (
|
|
288
|
+
<>
|
|
289
|
+
{onCommitAnimatedProperty && (
|
|
290
|
+
<Cube3dControl
|
|
291
|
+
element={element}
|
|
292
|
+
gsapRuntimeValues={gsapRuntimeValues}
|
|
293
|
+
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
|
294
|
+
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
|
295
|
+
onLivePreviewProps={onLivePreviewProps}
|
|
296
|
+
keyframed={(gsapKeyframes ?? []).some(
|
|
297
|
+
(kf) =>
|
|
298
|
+
"rotationX" in kf.properties ||
|
|
299
|
+
"rotationY" in kf.properties ||
|
|
300
|
+
"rotationZ" in kf.properties,
|
|
301
|
+
)}
|
|
302
|
+
onKeyframe={() => {
|
|
303
|
+
// Convert the 3D ("other"-group) static set to keyframes so the
|
|
304
|
+
// cube can animate; spans the element's clip via elDuration.
|
|
305
|
+
const id = resolveAnimIdForProp?.("rotationX") ?? gsapAnimId;
|
|
306
|
+
if (id) onConvertToKeyframes?.(id, elDuration);
|
|
85
307
|
}}
|
|
86
308
|
/>
|
|
87
309
|
)}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
310
|
+
<div className={RESPONSIVE_GRID}>
|
|
311
|
+
<Transform3dField
|
|
312
|
+
ctx={ctx}
|
|
313
|
+
label="Z"
|
|
314
|
+
prop="z"
|
|
315
|
+
scrub
|
|
316
|
+
format={formatPxMetricValue}
|
|
317
|
+
parse={parsePxMetricValue}
|
|
318
|
+
defaultValue={0}
|
|
319
|
+
/>
|
|
320
|
+
<Transform3dField
|
|
321
|
+
ctx={ctx}
|
|
92
322
|
label="Scale"
|
|
93
|
-
|
|
323
|
+
prop="scale"
|
|
94
324
|
scrub
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
void onCommitAnimatedProperty(element, "scale", v);
|
|
99
|
-
}
|
|
100
|
-
}}
|
|
325
|
+
format={(v) => String(v)}
|
|
326
|
+
parse={parseScale}
|
|
327
|
+
defaultValue={1}
|
|
101
328
|
/>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
onAddKeyframe={() => {
|
|
110
|
-
if (onCommitAnimatedProperty) {
|
|
111
|
-
void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
|
|
112
|
-
}
|
|
113
|
-
}}
|
|
114
|
-
onRemoveKeyframe={(pct) => {
|
|
115
|
-
const id = idFor("scale");
|
|
116
|
-
if (id) onRemoveKeyframe?.(id, pct);
|
|
117
|
-
}}
|
|
118
|
-
onConvertToKeyframes={() => {
|
|
119
|
-
const id = idFor("scale");
|
|
120
|
-
if (id) onConvertToKeyframes?.(id);
|
|
121
|
-
}}
|
|
329
|
+
<Transform3dField
|
|
330
|
+
ctx={ctx}
|
|
331
|
+
label="RotX"
|
|
332
|
+
prop="rotationX"
|
|
333
|
+
format={(v) => `${v}°`}
|
|
334
|
+
parse={parseDeg}
|
|
335
|
+
defaultValue={0}
|
|
122
336
|
/>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
337
|
+
<Transform3dField
|
|
338
|
+
ctx={ctx}
|
|
339
|
+
label="RotY"
|
|
340
|
+
prop="rotationY"
|
|
341
|
+
format={(v) => `${v}°`}
|
|
342
|
+
parse={parseDeg}
|
|
343
|
+
defaultValue={0}
|
|
344
|
+
/>
|
|
345
|
+
<Transform3dField
|
|
346
|
+
ctx={ctx}
|
|
347
|
+
label="RotZ"
|
|
348
|
+
prop="rotationZ"
|
|
349
|
+
format={(v) => `${v}°`}
|
|
350
|
+
parse={parseDeg}
|
|
351
|
+
defaultValue={0}
|
|
352
|
+
/>
|
|
353
|
+
<Transform3dField
|
|
354
|
+
ctx={ctx}
|
|
355
|
+
label="Perspective"
|
|
356
|
+
prop="transformPerspective"
|
|
357
|
+
scrub
|
|
358
|
+
format={formatPxMetricValue}
|
|
359
|
+
parse={parsePxNonNeg}
|
|
360
|
+
defaultValue={0}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
</>
|
|
364
|
+
)}
|
|
146
365
|
</div>
|
|
147
366
|
);
|
|
148
367
|
}
|