@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.
Files changed (43) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
  2. package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
  3. package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2715 -2061
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/manualOffsetDrag.test.ts +99 -0
  18. package/src/components/editor/manualOffsetDrag.ts +38 -7
  19. package/src/components/editor/marqueeCommit.ts +63 -20
  20. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  21. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  22. package/src/components/editor/transform3dProjection.test.ts +99 -0
  23. package/src/components/editor/transform3dProjection.ts +172 -0
  24. package/src/components/sidebar/AssetsTab.tsx +23 -213
  25. package/src/components/sidebar/AudioRow.tsx +202 -0
  26. package/src/contexts/DomEditContext.tsx +4 -0
  27. package/src/hooks/gsapDragCommit.test.ts +9 -5
  28. package/src/hooks/gsapDragCommit.ts +28 -9
  29. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  30. package/src/hooks/gsapRuntimePatch.ts +162 -35
  31. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  32. package/src/hooks/useDomEditCommits.ts +0 -2
  33. package/src/hooks/useDomEditSession.ts +4 -2
  34. package/src/hooks/useDomGeometryCommits.ts +3 -31
  35. package/src/hooks/useEnableKeyframes.test.ts +40 -0
  36. package/src/hooks/useEnableKeyframes.ts +9 -2
  37. package/src/hooks/useGsapAwareEditing.ts +31 -1
  38. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  39. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  40. package/src/hooks/useGsapTweenCache.ts +6 -4
  41. package/src/utils/marqueeGeometry.test.ts +15 -98
  42. package/src/utils/marqueeGeometry.ts +1 -158
  43. 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
- const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
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
- <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
50
- 3D Transform
51
- </div>
52
- <div className={RESPONSIVE_GRID}>
53
- <div className="flex items-center gap-1">
54
- <div className="flex-1">
55
- <MetricField
56
- label="Z"
57
- value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
58
- scrub
59
- onCommit={(next) => {
60
- const v = parsePxMetricValue(next);
61
- if (v != null && onCommitAnimatedProperty) {
62
- void onCommitAnimatedProperty(element, "z", v);
63
- }
64
- }}
65
- />
66
- </div>
67
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
68
- <KeyframeNavigation
69
- property="z"
70
- keyframes={gsapKeyframes}
71
- currentPercentage={currentPct}
72
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
73
- onAddKeyframe={() => {
74
- if (onCommitAnimatedProperty) {
75
- void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
76
- }
77
- }}
78
- onRemoveKeyframe={(pct) => {
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
- </div>
89
- <div className="flex items-center gap-1">
90
- <div className="flex-1">
91
- <MetricField
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
- value={String(gsapRuntimeValues.scale ?? 1)}
323
+ prop="scale"
94
324
  scrub
95
- onCommit={(next) => {
96
- const v = Number.parseFloat(next);
97
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
98
- void onCommitAnimatedProperty(element, "scale", v);
99
- }
100
- }}
325
+ format={(v) => String(v)}
326
+ parse={parseScale}
327
+ defaultValue={1}
101
328
  />
102
- </div>
103
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
104
- <KeyframeNavigation
105
- property="scale"
106
- keyframes={gsapKeyframes}
107
- currentPercentage={currentPct}
108
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
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
- </div>
125
- <MetricField
126
- label="RotX"
127
- value={`${gsapRuntimeValues.rotationX ?? 0}°`}
128
- onCommit={(next) => {
129
- const v = Number.parseFloat(next.replace("°", ""));
130
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
131
- void onCommitAnimatedProperty(element, "rotationX", v);
132
- }
133
- }}
134
- />
135
- <MetricField
136
- label="RotY"
137
- value={`${gsapRuntimeValues.rotationY ?? 0}°`}
138
- onCommit={(next) => {
139
- const v = Number.parseFloat(next.replace("°", ""));
140
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
141
- void onCommitAnimatedProperty(element, "rotationY", v);
142
- }
143
- }}
144
- />
145
- </div>
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
- // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
216
- export const EMPTY_FILTER_VALUE = "none";
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
- // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
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 = new Set<string>();
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
+ });