@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"dompurify": "^3.2.4",
|
|
46
46
|
"marked": "^14.1.4",
|
|
47
47
|
"mediabunny": "^1.45.3",
|
|
48
|
-
"@hyperframes/core": "0.7.
|
|
49
|
-
"@hyperframes/sdk": "0.7.
|
|
50
|
-
"@hyperframes/player": "0.7.
|
|
48
|
+
"@hyperframes/core": "0.7.9",
|
|
49
|
+
"@hyperframes/sdk": "0.7.9",
|
|
50
|
+
"@hyperframes/player": "0.7.9"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/react": "19",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"vite": "^6.4.2",
|
|
63
63
|
"vitest": "^3.2.4",
|
|
64
64
|
"zustand": "^5.0.0",
|
|
65
|
-
"@hyperframes/producer": "0.7.
|
|
65
|
+
"@hyperframes/producer": "0.7.9"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"react": "19",
|
|
@@ -118,6 +118,7 @@ export function StudioRightPanel({
|
|
|
118
118
|
handleGsapAddFromProperty,
|
|
119
119
|
handleGsapRemoveFromProperty,
|
|
120
120
|
commitAnimatedProperty,
|
|
121
|
+
commitAnimatedProperties,
|
|
121
122
|
handleSetArcPath,
|
|
122
123
|
handleUpdateArcSegment,
|
|
123
124
|
handleUnroll,
|
|
@@ -269,9 +270,12 @@ export function StudioRightPanel({
|
|
|
269
270
|
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
|
270
271
|
onAddGsapAnimation={handleGsapAddAnimation}
|
|
271
272
|
onCommitAnimatedProperty={commitAnimatedProperty}
|
|
273
|
+
onCommitAnimatedProperties={commitAnimatedProperties}
|
|
272
274
|
onAddKeyframe={handleGsapAddKeyframe}
|
|
273
275
|
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
|
274
|
-
onConvertToKeyframes={
|
|
276
|
+
onConvertToKeyframes={(animId, duration) =>
|
|
277
|
+
handleGsapConvertToKeyframes(animId, undefined, duration)
|
|
278
|
+
}
|
|
275
279
|
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
|
276
280
|
onSetArcPath={handleSetArcPath}
|
|
277
281
|
onUpdateArcSegment={handleUpdateArcSegment}
|
|
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac
|
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { type DomEditSelection } from "./domEditing";
|
|
4
4
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
5
|
+
import { MarqueeOverlay } from "./MarqueeOverlay";
|
|
5
6
|
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
|
|
6
7
|
import { collectDomEditLayerItems } from "./domEditingLayers";
|
|
7
8
|
import { isElementComputedVisible } from "./domEditingElement";
|
|
@@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
101
102
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
102
103
|
onMarqueeSelectRef.current = onMarqueeSelect;
|
|
103
104
|
|
|
105
|
+
// fallow-ignore-next-line complexity
|
|
104
106
|
const selectionShapeStyles = (() => {
|
|
105
107
|
const fallback = {
|
|
106
108
|
borderRadius: 8 as string | number,
|
|
@@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
226
228
|
// outside the composition bounds so users can find them.
|
|
227
229
|
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
228
230
|
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
231
|
+
// fallow-ignore-next-line complexity
|
|
229
232
|
useEffect(() => {
|
|
230
233
|
const iframe = iframeRef.current;
|
|
231
234
|
const overlay = overlayRef.current;
|
|
@@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
568
571
|
activeCompositionPathRef={activeCompositionPathRef}
|
|
569
572
|
onSelectionChangeRef={onSelectionChangeRef}
|
|
570
573
|
/>
|
|
571
|
-
{marquee.marqueeRect
|
|
572
|
-
<div
|
|
573
|
-
aria-hidden="true"
|
|
574
|
-
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
|
|
575
|
-
style={{
|
|
576
|
-
left: marquee.marqueeRect.left,
|
|
577
|
-
top: marquee.marqueeRect.top,
|
|
578
|
-
width: marquee.marqueeRect.width,
|
|
579
|
-
height: marquee.marqueeRect.height,
|
|
580
|
-
}}
|
|
581
|
-
/>
|
|
582
|
-
)}
|
|
574
|
+
<MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
|
|
583
575
|
<GridOverlay
|
|
584
576
|
visible={gridVisible}
|
|
585
577
|
spacing={gridSpacing}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Rect } from "../../utils/marqueeGeometry";
|
|
2
|
+
|
|
3
|
+
interface MarqueeOverlayProps {
|
|
4
|
+
/** Elements the marquee currently intersects — outlined live before mouse-up. */
|
|
5
|
+
candidateRects: Rect[];
|
|
6
|
+
/** The marquee drag rectangle itself, or null when not marquee-selecting. */
|
|
7
|
+
marqueeRect: Rect | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The marquee selection visuals: a live "candidate" outline on each element the
|
|
12
|
+
* box currently touches, plus the dashed drag rectangle. Extracted from
|
|
13
|
+
* DomEditOverlay to keep that file under the 600-line cap.
|
|
14
|
+
*/
|
|
15
|
+
export function MarqueeOverlay({ candidateRects, marqueeRect }: MarqueeOverlayProps) {
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{candidateRects.map((r, i) => (
|
|
19
|
+
<div
|
|
20
|
+
key={i}
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
className="pointer-events-none absolute rounded-sm border border-studio-accent bg-studio-accent/5"
|
|
23
|
+
style={{ left: r.left, top: r.top, width: r.width, height: r.height }}
|
|
24
|
+
/>
|
|
25
|
+
))}
|
|
26
|
+
{marqueeRect && (
|
|
27
|
+
<div
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
|
|
30
|
+
style={{
|
|
31
|
+
left: marqueeRect.left,
|
|
32
|
+
top: marqueeRect.top,
|
|
33
|
+
width: marqueeRect.width,
|
|
34
|
+
height: marqueeRect.height,
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -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
|
})}
|
|
@@ -90,6 +90,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
90
90
|
onRemoveKeyframe,
|
|
91
91
|
onConvertToKeyframes,
|
|
92
92
|
onCommitAnimatedProperty,
|
|
93
|
+
onCommitAnimatedProperties,
|
|
93
94
|
onSeekToTime,
|
|
94
95
|
recordingState,
|
|
95
96
|
recordingDuration,
|
|
@@ -523,9 +524,20 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
523
524
|
elDuration={elDuration}
|
|
524
525
|
element={element}
|
|
525
526
|
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
|
527
|
+
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
|
526
528
|
onSeekToTime={onSeekToTime}
|
|
527
529
|
onRemoveKeyframe={onRemoveKeyframe}
|
|
528
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
|
+
}}
|
|
529
541
|
/>
|
|
530
542
|
)}
|
|
531
543
|
<div className="mt-3">
|
|
@@ -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
|
+
}
|
|
@@ -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",
|
|
@@ -150,6 +154,7 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
|
|
|
150
154
|
rotationY: { step: 1 },
|
|
151
155
|
rotationZ: { step: 1 },
|
|
152
156
|
perspective: { min: 0, step: 1 },
|
|
157
|
+
transformPerspective: { min: 0, step: 1 },
|
|
153
158
|
skewX: { min: -90, max: 90, step: 1 },
|
|
154
159
|
skewY: { min: -90, max: 90, step: 1 },
|
|
155
160
|
width: { min: 0, step: 1 },
|
|
@@ -2,6 +2,7 @@ import { Window } from "happy-dom";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
applyManualOffsetDragCommit,
|
|
5
|
+
applyManualOffsetDragDraft,
|
|
5
6
|
applyManualOffsetDragMatrix,
|
|
6
7
|
createManualOffsetDragMember,
|
|
7
8
|
endManualOffsetDragMembers,
|
|
@@ -261,3 +262,101 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
|
|
|
261
262
|
}
|
|
262
263
|
});
|
|
263
264
|
});
|
|
265
|
+
|
|
266
|
+
// ── GSAP-element drag: the dot-a "flies" regressions ────────────────────────
|
|
267
|
+
// A static element positioned via the legacy `--hf-studio-offset` CSS var, dragged
|
|
268
|
+
// in a GSAP composition. Three independent failure modes, each fixed:
|
|
269
|
+
// 1. live drag integrated off-screen (base read from the live transform)
|
|
270
|
+
// 2. commit re-added the delta (stamped base wiped by a mid-drag re-render)
|
|
271
|
+
// 3. drop left the element offset (stale --hf-studio-offset var composing with
|
|
272
|
+
// the committed GSAP transform until a full reload)
|
|
273
|
+
function makeGsapDot(offsetX = 94, offsetY = 2) {
|
|
274
|
+
const window = new Window();
|
|
275
|
+
const element = window.document.createElement("div");
|
|
276
|
+
element.id = "dot-a";
|
|
277
|
+
element.setAttribute("data-hf-studio-path-offset", "true");
|
|
278
|
+
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${offsetX}px`);
|
|
279
|
+
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${offsetY}px`);
|
|
280
|
+
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
|
|
281
|
+
window.document.body.append(element);
|
|
282
|
+
// Constant rect → the screen-to-offset probe can't measure movement → member
|
|
283
|
+
// uses the deterministic preview-scale fallback matrix. Both branches set baseGsap.
|
|
284
|
+
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
|
|
285
|
+
const sets: Array<Record<string, unknown>> = [];
|
|
286
|
+
const win = element.ownerDocument.defaultView as unknown as {
|
|
287
|
+
gsap?: unknown;
|
|
288
|
+
__timelines?: unknown;
|
|
289
|
+
};
|
|
290
|
+
win.gsap = {
|
|
291
|
+
set: (el: HTMLElement, vars: Record<string, unknown>) => {
|
|
292
|
+
sets.push({ ...vars });
|
|
293
|
+
if (typeof vars.x === "number") {
|
|
294
|
+
el.style.setProperty("transform", `translate(${vars.x}px, ${(vars.y as number) ?? 0}px)`);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
// getProperty reads the LIVE transform — the exact value the old code fed back
|
|
298
|
+
// into `base + delta`, integrating the element off-screen.
|
|
299
|
+
getProperty: (el: HTMLElement, prop: string) => {
|
|
300
|
+
const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(
|
|
301
|
+
el.style.getPropertyValue("transform") || "",
|
|
302
|
+
);
|
|
303
|
+
if (!m) return 0;
|
|
304
|
+
return prop === "x" ? Number.parseFloat(m[1]!) : Number.parseFloat(m[2]!);
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
const member = () => {
|
|
308
|
+
const result = createManualOffsetDragMember({
|
|
309
|
+
key: "dot",
|
|
310
|
+
selection: { element } as never,
|
|
311
|
+
element,
|
|
312
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
313
|
+
});
|
|
314
|
+
if (!result.ok) throw new Error("member not created");
|
|
315
|
+
return result.member;
|
|
316
|
+
};
|
|
317
|
+
return { element, sets, member };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
describe("GSAP-element drag — dot-a flies regressions", () => {
|
|
321
|
+
it("live draft uses the stable gesture-start base, so repeated moves don't integrate", () => {
|
|
322
|
+
const { element, member } = makeGsapDot();
|
|
323
|
+
const m = member();
|
|
324
|
+
// Simulate a mid-drag re-render wiping the stamped base attr → the draft must
|
|
325
|
+
// fall back to the in-memory member.baseGsap, NOT the live (mutating) transform.
|
|
326
|
+
element.removeAttribute("data-hf-drag-gsap-base-x");
|
|
327
|
+
element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
328
|
+
applyManualOffsetDragDraft(m, -50, 0);
|
|
329
|
+
const first = element.style.getPropertyValue("transform");
|
|
330
|
+
applyManualOffsetDragDraft(m, -50, 0);
|
|
331
|
+
const second = element.style.getPropertyValue("transform");
|
|
332
|
+
// Same pointer delta → same committed transform. The old bug integrated (the
|
|
333
|
+
// second frame added the delta on top of the first frame's result).
|
|
334
|
+
expect(second).toBe(first);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("commit re-stamps the stable base/initial attrs even after they're wiped", () => {
|
|
338
|
+
const { element, member } = makeGsapDot();
|
|
339
|
+
const m = member();
|
|
340
|
+
element.removeAttribute("data-hf-drag-gsap-base-x");
|
|
341
|
+
element.removeAttribute("data-hf-drag-initial-offset-x");
|
|
342
|
+
applyManualOffsetDragCommit(m, -50, 0);
|
|
343
|
+
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe(String(m.baseGsap.x));
|
|
344
|
+
expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe(String(m.initialOffset.x));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("a GSAP-committed drag migrates the element off --hf-studio-offset", () => {
|
|
348
|
+
const { element, member } = makeGsapDot();
|
|
349
|
+
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("94px");
|
|
350
|
+
const m = member();
|
|
351
|
+
applyManualOffsetDragCommit(m, -160, 0);
|
|
352
|
+
endManualOffsetDragMembers([m]);
|
|
353
|
+
// The legacy CSS-offset channel is fully cleared (single-sourced in GSAP): the
|
|
354
|
+
// var is removed, so any lingering `translate: var(--hf-studio-offset-x, 0px)`
|
|
355
|
+
// resolves to its 0px fallback and can no longer compose with the GSAP transform.
|
|
356
|
+
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
|
357
|
+
expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
|
358
|
+
expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
|
|
359
|
+
// ...and the position survives in the GSAP transform (no stale var to compose).
|
|
360
|
+
expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
|
|
361
|
+
});
|
|
362
|
+
});
|