@hyperframes/studio 0.7.7 → 0.7.9
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-B7fv-WA3.js} +1 -1
- package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
- 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 +2715 -2061
- 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/manualOffsetDrag.test.ts +99 -0
- package/src/components/editor/manualOffsetDrag.ts +38 -7
- 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/useEnableKeyframes.test.ts +40 -0
- package/src/hooks/useEnableKeyframes.ts +9 -2
- 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
|
@@ -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
|
}
|
|
@@ -68,12 +68,18 @@ export interface PropertyPanelProps {
|
|
|
68
68
|
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
|
69
69
|
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
70
70
|
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
71
|
-
onConvertToKeyframes?: (animationId: string) => void;
|
|
71
|
+
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
|
72
72
|
onCommitAnimatedProperty?: (
|
|
73
73
|
selection: DomEditSelection,
|
|
74
74
|
property: string,
|
|
75
75
|
value: number | string,
|
|
76
76
|
) => Promise<void>;
|
|
77
|
+
/** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's
|
|
78
|
+
* rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */
|
|
79
|
+
onCommitAnimatedProperties?: (
|
|
80
|
+
selection: DomEditSelection,
|
|
81
|
+
props: Record<string, number | string>,
|
|
82
|
+
) => Promise<void>;
|
|
77
83
|
onSeekToTime?: (time: number) => void;
|
|
78
84
|
recordingState?: "idle" | "recording" | "preview";
|
|
79
85
|
recordingDuration?: number;
|
|
@@ -212,10 +218,8 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
|
|
|
212
218
|
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
213
219
|
export const EMPTY_STYLES: Record<string, string> = {};
|
|
214
220
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
218
|
-
export const BOX_SHADOW_PRESETS = {
|
|
221
|
+
const EMPTY_FILTER_VALUE = "none";
|
|
222
|
+
const BOX_SHADOW_PRESETS = {
|
|
219
223
|
none: "none",
|
|
220
224
|
soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
|
|
221
225
|
lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
|
|
@@ -275,13 +279,7 @@ export function parsePxMetricValue(value: string): number | null {
|
|
|
275
279
|
return token.value;
|
|
276
280
|
}
|
|
277
281
|
|
|
278
|
-
|
|
279
|
-
export function clampPanelNumber(
|
|
280
|
-
value: number,
|
|
281
|
-
min: number,
|
|
282
|
-
max: number,
|
|
283
|
-
fallback: number,
|
|
284
|
-
): number {
|
|
282
|
+
function clampPanelNumber(value: number, min: number, max: number, fallback: number): number {
|
|
285
283
|
if (!Number.isFinite(value)) return fallback;
|
|
286
284
|
return Math.max(min, Math.min(max, value));
|
|
287
285
|
}
|
|
@@ -491,6 +489,38 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
|
|
|
491
489
|
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
|
|
492
490
|
|
|
493
491
|
// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
492
|
+
// Core transform channels the panel ALWAYS reads live — even before a just-set
|
|
493
|
+
// value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the
|
|
494
|
+
// cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty
|
|
495
|
+
// reflects the in-place instant patch, so it's the true current value.
|
|
496
|
+
const ALWAYS_READ_CHANNELS = [
|
|
497
|
+
"x",
|
|
498
|
+
"y",
|
|
499
|
+
"rotation",
|
|
500
|
+
"rotationX",
|
|
501
|
+
"rotationY",
|
|
502
|
+
"rotationZ",
|
|
503
|
+
"z",
|
|
504
|
+
"scale",
|
|
505
|
+
"transformPerspective",
|
|
506
|
+
"opacity",
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
/** Every property key the panel should read for an element: animated props + the
|
|
510
|
+
* always-read transform channels. */
|
|
511
|
+
function collectPanelPropKeys(gsapAnimations: GsapAnimation[]): Set<string> {
|
|
512
|
+
const keys = new Set<string>(ALWAYS_READ_CHANNELS);
|
|
513
|
+
for (const anim of gsapAnimations) {
|
|
514
|
+
if (anim.keyframes) {
|
|
515
|
+
for (const kf of anim.keyframes.keyframes) {
|
|
516
|
+
for (const p of Object.keys(kf.properties)) keys.add(p);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const p of Object.keys(anim.properties)) keys.add(p);
|
|
520
|
+
}
|
|
521
|
+
return keys;
|
|
522
|
+
}
|
|
523
|
+
|
|
494
524
|
export function readGsapRuntimeValuesForPanel(
|
|
495
525
|
gsapAnimId: string | null,
|
|
496
526
|
gsapAnimations: GsapAnimation[],
|
|
@@ -511,15 +541,7 @@ export function readGsapRuntimeValuesForPanel(
|
|
|
511
541
|
if (!gsap?.getProperty) return null;
|
|
512
542
|
const el = iframe.contentDocument?.querySelector(selector);
|
|
513
543
|
if (!el) return null;
|
|
514
|
-
const propKeys =
|
|
515
|
-
for (const anim of gsapAnimations) {
|
|
516
|
-
if (anim.keyframes) {
|
|
517
|
-
for (const kf of anim.keyframes.keyframes) {
|
|
518
|
-
for (const p of Object.keys(kf.properties)) propKeys.add(p);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
|
522
|
-
}
|
|
544
|
+
const propKeys = collectPanelPropKeys(gsapAnimations);
|
|
523
545
|
const result: Record<string, number> = {};
|
|
524
546
|
for (const prop of propKeys) {
|
|
525
547
|
const v = Number(gsap.getProperty(el, prop));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { projectAxes, projectCubeFaces, rotate, wrapDeg } from "./transform3dProjection";
|
|
3
|
+
|
|
4
|
+
const OPTS = { cx: 50, cy: 50, r: 30, persp: 4 };
|
|
5
|
+
|
|
6
|
+
describe("projectCubeFaces", () => {
|
|
7
|
+
it("shows the front face (and only front-facing faces) at identity", () => {
|
|
8
|
+
const faces = projectCubeFaces(0, 0, 0, OPTS);
|
|
9
|
+
const ids = faces.map((f) => f.id);
|
|
10
|
+
expect(ids).toContain("front");
|
|
11
|
+
// Head-on: side/top/bottom normals are edge-on (n.z ≈ 0) → culled.
|
|
12
|
+
expect(ids).not.toContain("back");
|
|
13
|
+
expect(faces.length).toBeGreaterThanOrEqual(1);
|
|
14
|
+
expect(faces.length).toBeLessThanOrEqual(3);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reveals the top face when tilted forward on X", () => {
|
|
18
|
+
const ids = projectCubeFaces(45, 0, 0, OPTS).map((f) => f.id);
|
|
19
|
+
expect(ids).toContain("front");
|
|
20
|
+
expect(ids).toContain("top");
|
|
21
|
+
expect(ids).not.toContain("bottom");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reveals a side face when rotated on Y (CSS rotateY convention)", () => {
|
|
25
|
+
const ids = projectCubeFaces(0, 45, 0, OPTS).map((f) => f.id);
|
|
26
|
+
expect(ids).toContain("front");
|
|
27
|
+
expect(ids).toContain("left");
|
|
28
|
+
expect(ids).not.toContain("right");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never returns more than 3 faces (a cube shows at most 3 at once)", () => {
|
|
32
|
+
for (const [rx, ry, rz] of [
|
|
33
|
+
[30, 30, 0],
|
|
34
|
+
[60, 60, 45],
|
|
35
|
+
[135, 20, 90],
|
|
36
|
+
]) {
|
|
37
|
+
expect(projectCubeFaces(rx, ry, rz, OPTS).length).toBeLessThanOrEqual(3);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rotationZ rolls the silhouette without changing which faces are visible", () => {
|
|
42
|
+
const a = projectCubeFaces(0, 0, 0, OPTS)
|
|
43
|
+
.map((f) => f.id)
|
|
44
|
+
.sort();
|
|
45
|
+
const b = projectCubeFaces(0, 0, 90, OPTS)
|
|
46
|
+
.map((f) => f.id)
|
|
47
|
+
.sort();
|
|
48
|
+
expect(b).toEqual(a);
|
|
49
|
+
// …but the projected coordinates differ (it rolled).
|
|
50
|
+
expect(projectCubeFaces(0, 0, 90, OPTS)[0]?.points).not.toEqual(
|
|
51
|
+
projectCubeFaces(0, 0, 0, OPTS)[0]?.points,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("paints far faces before near faces (painter's order)", () => {
|
|
56
|
+
const faces = projectCubeFaces(35, 35, 0, OPTS);
|
|
57
|
+
for (let i = 1; i < faces.length; i++) {
|
|
58
|
+
expect(faces[i]!.depth).toBeGreaterThanOrEqual(faces[i - 1]!.depth);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("projectAxes", () => {
|
|
64
|
+
it("returns the three X/Y/Z axes with standard colors", () => {
|
|
65
|
+
const axes = projectAxes(0, 0, 0, OPTS);
|
|
66
|
+
expect(axes.map((a) => a.id).sort()).toEqual(["x", "y", "z"]);
|
|
67
|
+
const z = axes.find((a) => a.id === "z")!;
|
|
68
|
+
expect(z.color).toMatch(/#/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("flags the toward-viewer axis as front at identity (Z points at the camera)", () => {
|
|
72
|
+
const z = projectAxes(0, 0, 0, OPTS).find((a) => a.id === "z")!;
|
|
73
|
+
expect(z.front).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rotating 180° on Y flips the Z axis away from the viewer", () => {
|
|
77
|
+
const z = projectAxes(0, 180, 0, OPTS).find((a) => a.id === "z")!;
|
|
78
|
+
expect(z.front).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("rotate", () => {
|
|
83
|
+
it("90° on Y maps +x to -z (right edge swings to the back)", () => {
|
|
84
|
+
const v = rotate({ x: 1, y: 0, z: 0 }, 0, 90, 0);
|
|
85
|
+
expect(v.x).toBeCloseTo(0, 5);
|
|
86
|
+
expect(v.z).toBeCloseTo(-1, 5);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("wrapDeg", () => {
|
|
91
|
+
it("wraps into (-180, 180]", () => {
|
|
92
|
+
expect(wrapDeg(0)).toBe(0);
|
|
93
|
+
expect(wrapDeg(180)).toBe(180);
|
|
94
|
+
expect(wrapDeg(190)).toBe(-170);
|
|
95
|
+
expect(wrapDeg(-190)).toBe(170);
|
|
96
|
+
expect(wrapDeg(360)).toBe(0);
|
|
97
|
+
expect(wrapDeg(540)).toBe(180);
|
|
98
|
+
});
|
|
99
|
+
});
|