@hyperframes/studio 0.6.85 → 0.6.87
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/{hyperframes-player-DRpY3xHh.js → hyperframes-player-0esDKGRk.js} +1 -1
- package/dist/assets/index-BA19FAPN.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-DHcptK1_.css +0 -1
- package/dist/assets/index-DtSCUvYQ.js +0 -140
|
@@ -60,9 +60,9 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
60
60
|
refreshKey,
|
|
61
61
|
compositionLoading,
|
|
62
62
|
timelineElements,
|
|
63
|
-
currentTime,
|
|
64
63
|
showToast,
|
|
65
64
|
} = useStudioContext();
|
|
65
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
66
66
|
const {
|
|
67
67
|
domEditSelection,
|
|
68
68
|
applyDomSelection,
|
|
@@ -239,9 +239,9 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
239
239
|
|
|
240
240
|
if (layers.length === 0) {
|
|
241
241
|
return (
|
|
242
|
-
<div className="flex h-full flex-col items-center justify-center bg-
|
|
243
|
-
<Layers size={18} className="mb-3 text-
|
|
244
|
-
<p className="text-sm font-medium text-
|
|
242
|
+
<div className="flex h-full flex-col items-center justify-center bg-panel-bg px-6 text-center">
|
|
243
|
+
<Layers size={18} className="mb-3 text-panel-text-5" />
|
|
244
|
+
<p className="text-sm font-medium text-panel-text-1">No layers</p>
|
|
245
245
|
<p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
|
|
246
246
|
</div>
|
|
247
247
|
);
|
|
@@ -249,10 +249,10 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
249
249
|
|
|
250
250
|
return (
|
|
251
251
|
<div
|
|
252
|
-
className="flex h-full min-h-0 flex-col overflow-hidden bg-
|
|
252
|
+
className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg"
|
|
253
253
|
onPointerLeave={() => handleLayerHover(null)}
|
|
254
254
|
>
|
|
255
|
-
<div className="border-b border-
|
|
255
|
+
<div className="border-b border-panel-border px-3 py-2 text-[11px] text-panel-text-3">
|
|
256
256
|
{layers.length} layer{layers.length === 1 ? "" : "s"}
|
|
257
257
|
</div>
|
|
258
258
|
<div
|
|
@@ -289,8 +289,8 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
289
289
|
isDragged
|
|
290
290
|
? "opacity-40"
|
|
291
291
|
: selected
|
|
292
|
-
? "bg-
|
|
293
|
-
: "text-
|
|
292
|
+
? "bg-panel-accent/14 text-panel-accent"
|
|
293
|
+
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
|
294
294
|
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
|
295
295
|
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
|
296
296
|
>
|
|
@@ -316,17 +316,19 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
316
316
|
<span
|
|
317
317
|
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
|
|
318
318
|
selected
|
|
319
|
-
? "bg-
|
|
319
|
+
? "bg-panel-accent/18 text-panel-accent"
|
|
320
320
|
: isCompHost
|
|
321
|
-
? "bg-
|
|
322
|
-
: "bg-
|
|
321
|
+
? "bg-panel-accent/40 text-panel-accent"
|
|
322
|
+
: "bg-panel-hover text-panel-text-4"
|
|
323
323
|
}`}
|
|
324
324
|
>
|
|
325
325
|
{getTagBadge(layer.tagName)}
|
|
326
326
|
</span>
|
|
327
327
|
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
|
|
328
328
|
{hasChildren && (
|
|
329
|
-
<span className="text-[9px] tabular-nums text-
|
|
329
|
+
<span className="text-[9px] tabular-nums text-panel-text-5">
|
|
330
|
+
{layer.childCount}
|
|
331
|
+
</span>
|
|
330
332
|
)}
|
|
331
333
|
</div>
|
|
332
334
|
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { memo, useMemo, type RefObject } from "react";
|
|
2
|
+
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
|
|
4
|
+
interface MotionPathOverlayProps {
|
|
5
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
6
|
+
arcPath: ArcPathConfig | null;
|
|
7
|
+
waypoints: Array<{ x: number; y: number }> | null;
|
|
8
|
+
elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildSvgPath(
|
|
12
|
+
waypoints: Array<{ x: number; y: number }>,
|
|
13
|
+
segments: ArcPathConfig["segments"],
|
|
14
|
+
base: { left: number; top: number; scaleX: number; scaleY: number },
|
|
15
|
+
): string {
|
|
16
|
+
if (waypoints.length < 2) return "";
|
|
17
|
+
|
|
18
|
+
const toPixel = (wp: { x: number; y: number }) => ({
|
|
19
|
+
x: base.left + wp.x * base.scaleX,
|
|
20
|
+
y: base.top + wp.y * base.scaleY,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const first = toPixel(waypoints[0]!);
|
|
24
|
+
const parts = [`M ${first.x} ${first.y}`];
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
|
|
27
|
+
const seg = segments[i]!;
|
|
28
|
+
const end = toPixel(waypoints[i + 1]!);
|
|
29
|
+
|
|
30
|
+
if (seg.cp1 && seg.cp2) {
|
|
31
|
+
const c1 = toPixel(seg.cp1);
|
|
32
|
+
const c2 = toPixel(seg.cp2);
|
|
33
|
+
parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
|
|
34
|
+
} else {
|
|
35
|
+
const start = toPixel(waypoints[i]!);
|
|
36
|
+
const dx = end.x - start.x;
|
|
37
|
+
const dy = end.y - start.y;
|
|
38
|
+
const c = seg.curviness ?? 1;
|
|
39
|
+
const offset = c * Math.abs(dx) * 0.25;
|
|
40
|
+
const c1x = start.x + dx * 0.33;
|
|
41
|
+
const c1y = start.y + dy * 0.33 - offset;
|
|
42
|
+
const c2x = start.x + dx * 0.66;
|
|
43
|
+
const c2y = start.y + dy * 0.66 - offset;
|
|
44
|
+
parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return parts.join(" ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
52
|
+
arcPath,
|
|
53
|
+
waypoints,
|
|
54
|
+
elementBaseRect,
|
|
55
|
+
}: MotionPathOverlayProps) {
|
|
56
|
+
const pathD = useMemo(() => {
|
|
57
|
+
if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
|
|
58
|
+
return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
|
|
59
|
+
}, [arcPath, waypoints, elementBaseRect]);
|
|
60
|
+
|
|
61
|
+
const anchorPoints = useMemo(() => {
|
|
62
|
+
if (!waypoints || !elementBaseRect) return [];
|
|
63
|
+
return waypoints.map((wp) => ({
|
|
64
|
+
x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
|
|
65
|
+
y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
|
|
66
|
+
}));
|
|
67
|
+
}, [waypoints, elementBaseRect]);
|
|
68
|
+
|
|
69
|
+
const controlPoints = useMemo(() => {
|
|
70
|
+
if (!arcPath?.enabled || !elementBaseRect) return [];
|
|
71
|
+
const points: Array<{
|
|
72
|
+
segIndex: number;
|
|
73
|
+
type: "cp1" | "cp2";
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
anchorX: number;
|
|
77
|
+
anchorY: number;
|
|
78
|
+
}> = [];
|
|
79
|
+
for (let i = 0; i < arcPath.segments.length; i++) {
|
|
80
|
+
const seg = arcPath.segments[i]!;
|
|
81
|
+
if (seg.cp1 && seg.cp2 && waypoints) {
|
|
82
|
+
const anchor1 = waypoints[i]!;
|
|
83
|
+
const anchor2 = waypoints[i + 1]!;
|
|
84
|
+
points.push({
|
|
85
|
+
segIndex: i,
|
|
86
|
+
type: "cp1",
|
|
87
|
+
x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
|
|
88
|
+
y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
|
|
89
|
+
anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
|
|
90
|
+
anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
|
|
91
|
+
});
|
|
92
|
+
points.push({
|
|
93
|
+
segIndex: i,
|
|
94
|
+
type: "cp2",
|
|
95
|
+
x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
|
|
96
|
+
y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
|
|
97
|
+
anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
|
|
98
|
+
anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return points;
|
|
103
|
+
}, [arcPath, waypoints, elementBaseRect]);
|
|
104
|
+
|
|
105
|
+
if (!pathD) return null;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
|
|
109
|
+
<path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
|
|
110
|
+
|
|
111
|
+
{controlPoints.map((cp) => (
|
|
112
|
+
<g key={`${cp.segIndex}-${cp.type}`}>
|
|
113
|
+
<line
|
|
114
|
+
x1={cp.anchorX}
|
|
115
|
+
y1={cp.anchorY}
|
|
116
|
+
x2={cp.x}
|
|
117
|
+
y2={cp.y}
|
|
118
|
+
stroke="rgba(167, 139, 250, 0.3)"
|
|
119
|
+
strokeWidth={1}
|
|
120
|
+
strokeDasharray="3 2"
|
|
121
|
+
/>
|
|
122
|
+
<circle
|
|
123
|
+
cx={cp.x}
|
|
124
|
+
cy={cp.y}
|
|
125
|
+
r={4}
|
|
126
|
+
fill="#a78bfa"
|
|
127
|
+
className="pointer-events-auto cursor-grab"
|
|
128
|
+
/>
|
|
129
|
+
</g>
|
|
130
|
+
))}
|
|
131
|
+
|
|
132
|
+
{anchorPoints.map((pt, i) => (
|
|
133
|
+
<circle
|
|
134
|
+
key={i}
|
|
135
|
+
cx={pt.x}
|
|
136
|
+
cy={pt.y}
|
|
137
|
+
r={5}
|
|
138
|
+
fill="#3CE6AC"
|
|
139
|
+
stroke="rgba(255,255,255,0.5)"
|
|
140
|
+
strokeWidth={1}
|
|
141
|
+
className="pointer-events-auto cursor-pointer"
|
|
142
|
+
/>
|
|
143
|
+
))}
|
|
144
|
+
</svg>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
import { Eye, Layers,
|
|
1
|
+
import { memo, useRef, useState } from "react";
|
|
2
|
+
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
|
3
|
+
import { useStudioContext } from "../../contexts/StudioContext";
|
|
3
4
|
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
4
5
|
import {
|
|
5
6
|
EMPTY_STYLES,
|
|
6
7
|
formatPxMetricValue,
|
|
7
|
-
LABEL,
|
|
8
8
|
parsePxMetricValue,
|
|
9
9
|
RESPONSIVE_GRID,
|
|
10
10
|
} from "./propertyPanelHelpers";
|
|
@@ -16,7 +16,7 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
|
|
|
16
16
|
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
|
17
17
|
import { usePlayerStore } from "../../player";
|
|
18
18
|
import { TimingSection } from "./propertyPanelTimingSection";
|
|
19
|
-
import {
|
|
19
|
+
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
20
20
|
|
|
21
21
|
// Re-export helpers that external consumers import from this module
|
|
22
22
|
export {
|
|
@@ -41,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
41
41
|
assets,
|
|
42
42
|
element,
|
|
43
43
|
multiSelectCount = 0,
|
|
44
|
-
copiedAgentPrompt,
|
|
44
|
+
copiedAgentPrompt: _copiedAgentPrompt,
|
|
45
45
|
onClearSelection,
|
|
46
46
|
onSetStyle,
|
|
47
47
|
onSetAttribute,
|
|
@@ -53,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
53
53
|
onSetTextFieldStyle,
|
|
54
54
|
onAddTextField,
|
|
55
55
|
onRemoveTextField,
|
|
56
|
-
onAskAgent,
|
|
56
|
+
onAskAgent: _onAskAgent,
|
|
57
57
|
onImportAssets,
|
|
58
58
|
fontAssets = [],
|
|
59
59
|
onImportFonts,
|
|
@@ -70,13 +70,22 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
70
70
|
onAddGsapFromProperty,
|
|
71
71
|
onRemoveGsapFromProperty,
|
|
72
72
|
onAddGsapAnimation,
|
|
73
|
+
onSetArcPath,
|
|
74
|
+
onUpdateArcSegment,
|
|
73
75
|
onAddKeyframe,
|
|
74
76
|
onRemoveKeyframe,
|
|
75
77
|
onConvertToKeyframes,
|
|
76
78
|
onCommitAnimatedProperty,
|
|
77
79
|
onSeekToTime,
|
|
80
|
+
recordingState,
|
|
81
|
+
recordingDuration,
|
|
82
|
+
onToggleRecording,
|
|
78
83
|
}: PropertyPanelProps) {
|
|
79
84
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
85
|
+
const { showToast } = useStudioContext();
|
|
86
|
+
const [clipboardCopied, setClipboardCopied] = useState(false);
|
|
87
|
+
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
88
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
80
89
|
|
|
81
90
|
if (!element) {
|
|
82
91
|
return (
|
|
@@ -170,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
170
179
|
onSetManualRotation(element, { angle: parsed });
|
|
171
180
|
};
|
|
172
181
|
|
|
173
|
-
// Keyframe navigation state
|
|
174
182
|
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
|
|
175
183
|
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
|
|
176
|
-
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
177
184
|
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
|
178
185
|
|
|
179
186
|
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
|
|
@@ -217,6 +224,34 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
217
224
|
}
|
|
218
225
|
})();
|
|
219
226
|
|
|
227
|
+
const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
|
|
228
|
+
if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
|
|
229
|
+
const hasBRProp = gsapAnimations.some(
|
|
230
|
+
(a) =>
|
|
231
|
+
"borderRadius" in a.properties ||
|
|
232
|
+
a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
|
|
233
|
+
);
|
|
234
|
+
if (!hasBRProp) return null;
|
|
235
|
+
}
|
|
236
|
+
const iframe = previewIframeRef?.current;
|
|
237
|
+
const selector = element.id ? `#${element.id}` : element.selector;
|
|
238
|
+
if (!iframe?.contentDocument || !selector) return null;
|
|
239
|
+
try {
|
|
240
|
+
const el = iframe.contentDocument.querySelector(selector);
|
|
241
|
+
if (!el) return null;
|
|
242
|
+
const cs = iframe.contentWindow!.getComputedStyle(el);
|
|
243
|
+
const parse = (v: string) => Number.parseFloat(v) || 0;
|
|
244
|
+
return {
|
|
245
|
+
tl: parse(cs.borderTopLeftRadius),
|
|
246
|
+
tr: parse(cs.borderTopRightRadius),
|
|
247
|
+
br: parse(cs.borderBottomRightRadius),
|
|
248
|
+
bl: parse(cs.borderBottomLeftRadius),
|
|
249
|
+
};
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
|
|
220
255
|
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
|
221
256
|
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
|
222
257
|
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
|
|
@@ -224,34 +259,100 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
224
259
|
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
|
225
260
|
|
|
226
261
|
return (
|
|
227
|
-
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-
|
|
228
|
-
<div className="
|
|
229
|
-
<div className="flex items-
|
|
262
|
+
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
|
263
|
+
<div className="px-4 py-3">
|
|
264
|
+
<div className="flex items-center justify-between gap-4">
|
|
230
265
|
<div className="min-w-0">
|
|
231
|
-
<div className=
|
|
232
|
-
<div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
|
|
266
|
+
<div className="truncate text-[13px] font-semibold text-neutral-100">
|
|
233
267
|
{element.label}
|
|
234
268
|
</div>
|
|
235
|
-
<div className="mt-
|
|
269
|
+
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="flex items-center gap-1">
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={() => {
|
|
275
|
+
const file = element.sourceFile ?? "index.html";
|
|
276
|
+
let lineNum: number | null = null;
|
|
277
|
+
try {
|
|
278
|
+
const src =
|
|
279
|
+
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
|
280
|
+
if (src && element.id) {
|
|
281
|
+
const idx = src.indexOf(`id="${element.id}"`);
|
|
282
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
283
|
+
}
|
|
284
|
+
if (!lineNum && element.selector) {
|
|
285
|
+
const tag = element.tagName.toLowerCase();
|
|
286
|
+
const cls = element.selector.startsWith(".")
|
|
287
|
+
? element.selector.slice(1).split(".")[0]
|
|
288
|
+
: null;
|
|
289
|
+
const search = cls ? `class="${cls}` : `<${tag}`;
|
|
290
|
+
const idx = src.indexOf(search);
|
|
291
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
|
295
|
+
const lines = [
|
|
296
|
+
`Element: ${element.label} (${sourceLabel})`,
|
|
297
|
+
`File: ${fileLoc}`,
|
|
298
|
+
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
|
299
|
+
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
|
300
|
+
`Tag: <${element.tagName}>`,
|
|
301
|
+
];
|
|
302
|
+
if (
|
|
303
|
+
element.computedStyles["z-index"] &&
|
|
304
|
+
element.computedStyles["z-index"] !== "auto"
|
|
305
|
+
) {
|
|
306
|
+
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
|
307
|
+
}
|
|
308
|
+
if (gsapAnimations.length > 0) {
|
|
309
|
+
const anim = gsapAnimations[0];
|
|
310
|
+
lines.push(
|
|
311
|
+
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
|
312
|
+
);
|
|
313
|
+
const props = Object.entries(anim.properties)
|
|
314
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
315
|
+
.join(", ");
|
|
316
|
+
if (props) lines.push(`Properties: ${props}`);
|
|
317
|
+
}
|
|
318
|
+
const text = lines.join("\n");
|
|
319
|
+
void navigator.clipboard.writeText(text);
|
|
320
|
+
showToast(
|
|
321
|
+
`Copied element info for ${element.label} — paste into any AI agent`,
|
|
322
|
+
"info",
|
|
323
|
+
);
|
|
324
|
+
setClipboardCopied(true);
|
|
325
|
+
clearTimeout(clipboardTimerRef.current);
|
|
326
|
+
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
|
327
|
+
}}
|
|
328
|
+
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
|
329
|
+
clipboardCopied
|
|
330
|
+
? "text-studio-accent"
|
|
331
|
+
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
|
|
332
|
+
}`}
|
|
333
|
+
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
|
|
334
|
+
>
|
|
335
|
+
<svg
|
|
336
|
+
width="13"
|
|
337
|
+
height="13"
|
|
338
|
+
viewBox="0 0 16 16"
|
|
339
|
+
fill="none"
|
|
340
|
+
stroke="currentColor"
|
|
341
|
+
strokeWidth="1.5"
|
|
342
|
+
>
|
|
343
|
+
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
|
344
|
+
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
|
345
|
+
</svg>
|
|
346
|
+
</button>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
aria-label="Clear selection"
|
|
350
|
+
onClick={onClearSelection}
|
|
351
|
+
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
|
352
|
+
>
|
|
353
|
+
<X size={13} />
|
|
354
|
+
</button>
|
|
236
355
|
</div>
|
|
237
|
-
<button
|
|
238
|
-
type="button"
|
|
239
|
-
aria-label="Clear selection"
|
|
240
|
-
onClick={onClearSelection}
|
|
241
|
-
className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
|
|
242
|
-
>
|
|
243
|
-
<X size={13} />
|
|
244
|
-
</button>
|
|
245
|
-
</div>
|
|
246
|
-
<div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
|
|
247
|
-
<button
|
|
248
|
-
type="button"
|
|
249
|
-
onClick={onAskAgent}
|
|
250
|
-
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
|
|
251
|
-
>
|
|
252
|
-
<MessageSquare size={15} />
|
|
253
|
-
<span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
|
|
254
|
-
</button>
|
|
255
356
|
</div>
|
|
256
357
|
</div>
|
|
257
358
|
|
|
@@ -384,29 +485,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
384
485
|
/>
|
|
385
486
|
)}
|
|
386
487
|
</div>
|
|
387
|
-
{element.capabilities.canApplyManualSize && (
|
|
388
|
-
<button
|
|
389
|
-
type="button"
|
|
390
|
-
className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
|
391
|
-
title="Fit to children"
|
|
392
|
-
onClick={() => {
|
|
393
|
-
const size = computeFitToChildrenSize(element);
|
|
394
|
-
if (size) onSetManualSize(element, size);
|
|
395
|
-
}}
|
|
396
|
-
>
|
|
397
|
-
<svg
|
|
398
|
-
width="14"
|
|
399
|
-
height="14"
|
|
400
|
-
viewBox="0 0 14 14"
|
|
401
|
-
fill="none"
|
|
402
|
-
stroke="currentColor"
|
|
403
|
-
strokeWidth="1.2"
|
|
404
|
-
>
|
|
405
|
-
<rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
|
|
406
|
-
<path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
|
|
407
|
-
</svg>
|
|
408
|
-
</button>
|
|
409
|
-
)}
|
|
410
488
|
<div className="flex items-center gap-1">
|
|
411
489
|
<div className="flex-1">
|
|
412
490
|
<MetricField
|
|
@@ -467,17 +545,40 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
467
545
|
/>
|
|
468
546
|
)}
|
|
469
547
|
</div>
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
548
|
+
<div className="flex items-center gap-1">
|
|
549
|
+
<div className="flex-1">
|
|
550
|
+
<MetricField
|
|
551
|
+
label="Scale"
|
|
552
|
+
value={String(gsapRuntimeValues.scale ?? 1)}
|
|
553
|
+
scrub
|
|
554
|
+
onCommit={(next) => {
|
|
555
|
+
const v = Number.parseFloat(next);
|
|
556
|
+
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
|
557
|
+
void onCommitAnimatedProperty(element, "scale", v);
|
|
558
|
+
}
|
|
559
|
+
}}
|
|
560
|
+
/>
|
|
561
|
+
</div>
|
|
562
|
+
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
|
563
|
+
<KeyframeNavigation
|
|
564
|
+
property="scale"
|
|
565
|
+
keyframes={gsapKeyframes}
|
|
566
|
+
currentPercentage={currentPct}
|
|
567
|
+
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
|
568
|
+
onAddKeyframe={() => {
|
|
569
|
+
if (onCommitAnimatedProperty) {
|
|
570
|
+
void onCommitAnimatedProperty(
|
|
571
|
+
element,
|
|
572
|
+
"scale",
|
|
573
|
+
gsapRuntimeValues?.scale ?? 1,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}}
|
|
577
|
+
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
|
|
578
|
+
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
|
|
579
|
+
/>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
481
582
|
<MetricField
|
|
482
583
|
label="RotX"
|
|
483
584
|
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
|
|
@@ -533,9 +634,37 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
533
634
|
onAddFromProperty={onAddGsapFromProperty}
|
|
534
635
|
onRemoveFromProperty={onRemoveGsapFromProperty}
|
|
535
636
|
onAddAnimation={onAddGsapAnimation}
|
|
637
|
+
onSetArcPath={onSetArcPath}
|
|
638
|
+
onUpdateArcSegment={onUpdateArcSegment}
|
|
536
639
|
/>
|
|
537
640
|
)}
|
|
538
641
|
|
|
642
|
+
{onToggleRecording && (
|
|
643
|
+
<div className="px-4 pb-3">
|
|
644
|
+
<button
|
|
645
|
+
type="button"
|
|
646
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
647
|
+
onClick={onToggleRecording}
|
|
648
|
+
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
|
|
649
|
+
recordingState === "recording"
|
|
650
|
+
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
|
|
651
|
+
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
|
|
652
|
+
}`}
|
|
653
|
+
>
|
|
654
|
+
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
655
|
+
{recordingState === "recording" ? (
|
|
656
|
+
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
|
|
657
|
+
) : (
|
|
658
|
+
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
|
|
659
|
+
)}
|
|
660
|
+
</svg>
|
|
661
|
+
{recordingState === "recording"
|
|
662
|
+
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
|
|
663
|
+
: "Record gesture (R) — move pointer to capture motion"}
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
)}
|
|
667
|
+
|
|
539
668
|
{showEditableSections && (
|
|
540
669
|
<StyleSections
|
|
541
670
|
projectId={projectId}
|
|
@@ -544,6 +673,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
544
673
|
assets={assets}
|
|
545
674
|
onSetStyle={onSetStyle}
|
|
546
675
|
onImportAssets={onImportAssets}
|
|
676
|
+
gsapBorderRadius={gsapBorderRadius}
|
|
547
677
|
/>
|
|
548
678
|
)}
|
|
549
679
|
</div>
|
|
@@ -143,7 +143,6 @@ export const SourceEditor = memo(function SourceEditor({
|
|
|
143
143
|
selection: { anchor: pos },
|
|
144
144
|
effects: EditorView.scrollIntoView(pos, { y: "center" }),
|
|
145
145
|
});
|
|
146
|
-
view.focus();
|
|
147
146
|
}, [revealOffset]);
|
|
148
147
|
|
|
149
148
|
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { memo, useState } from "react";
|
|
2
|
+
import { MetricField } from "./propertyPanelPrimitives";
|
|
3
|
+
|
|
4
|
+
export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
|
|
5
|
+
|
|
6
|
+
interface StaggerControlsProps {
|
|
7
|
+
elementCount: number;
|
|
8
|
+
onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
|
|
12
|
+
const ORDER_LABELS: Record<StaggerOrder, string> = {
|
|
13
|
+
dom: "DOM order",
|
|
14
|
+
reverse: "Reverse",
|
|
15
|
+
center: "Center out",
|
|
16
|
+
edges: "Edges in",
|
|
17
|
+
random: "Random",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const StaggerControls = memo(function StaggerControls({
|
|
21
|
+
elementCount,
|
|
22
|
+
onApplyStagger,
|
|
23
|
+
}: StaggerControlsProps) {
|
|
24
|
+
const [offsetMs, setOffsetMs] = useState(80);
|
|
25
|
+
const [order, setOrder] = useState<StaggerOrder>("dom");
|
|
26
|
+
|
|
27
|
+
if (elementCount < 2) return null;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
|
|
31
|
+
<span className="text-[10px] font-medium text-neutral-500">Stagger</span>
|
|
32
|
+
<MetricField
|
|
33
|
+
label="Offset"
|
|
34
|
+
value={String(offsetMs)}
|
|
35
|
+
suffix="ms"
|
|
36
|
+
onCommit={(raw) => {
|
|
37
|
+
const v = Number.parseInt(raw, 10);
|
|
38
|
+
if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
<select
|
|
42
|
+
value={order}
|
|
43
|
+
onChange={(e) => setOrder(e.target.value as StaggerOrder)}
|
|
44
|
+
className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
|
|
45
|
+
>
|
|
46
|
+
{ORDER_OPTIONS.map((o) => (
|
|
47
|
+
<option key={o} value={o}>
|
|
48
|
+
{ORDER_LABELS[o]}
|
|
49
|
+
</option>
|
|
50
|
+
))}
|
|
51
|
+
</select>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onApplyStagger(offsetMs, order)}
|
|
55
|
+
className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
|
|
56
|
+
>
|
|
57
|
+
Apply ({elementCount})
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { selectionCacheKey } from "./domEditOverlayGeometry";
|
|
4
|
+
|
|
5
|
+
describe("selectionCacheKey — hfId collision (R7)", () => {
|
|
6
|
+
it("produces distinct keys for two elements that differ only by hfId", () => {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
const a = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-111" } as any);
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
const b = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-222" } as any);
|
|
11
|
+
expect(a).not.toBe(b);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -190,10 +190,11 @@ export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
export function selectionCacheKey(
|
|
193
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
|
193
|
+
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
|
194
194
|
): string {
|
|
195
195
|
return [
|
|
196
196
|
selection.sourceFile ?? "",
|
|
197
|
+
selection.hfId ?? "",
|
|
197
198
|
selection.id ?? "",
|
|
198
199
|
selection.selector ?? "",
|
|
199
200
|
selection.selectorIndex ?? "",
|