@hyperframes/studio 0.6.86 → 0.6.88
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-B9_ctmee.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-BT9VHgSy.js +0 -140
- package/dist/assets/index-DHcptK1_.css +0 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
|
|
1
3
|
import {
|
|
2
4
|
getNextTimelineZoomPercent,
|
|
3
5
|
getTimelineZoomPercent,
|
|
@@ -7,88 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player";
|
|
|
7
9
|
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
|
8
10
|
import { Tooltip } from "./ui";
|
|
9
11
|
import { Scissors } from "../icons/SystemIcons";
|
|
10
|
-
import type { GsapAnimation
|
|
12
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
13
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
keyframes: GsapPercentageKeyframe[],
|
|
15
|
-
pct: number,
|
|
16
|
-
): Record<string, number> {
|
|
17
|
-
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
|
18
|
-
const allProps = new Set<string>();
|
|
19
|
-
for (const kf of sorted) {
|
|
20
|
-
for (const p of Object.keys(kf.properties)) {
|
|
21
|
-
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
const result: Record<string, number> = {};
|
|
25
|
-
for (const prop of allProps) {
|
|
26
|
-
let prev: { pct: number; val: number } | null = null;
|
|
27
|
-
let next: { pct: number; val: number } | null = null;
|
|
28
|
-
for (const kf of sorted) {
|
|
29
|
-
const v = kf.properties[prop];
|
|
30
|
-
if (typeof v !== "number") continue;
|
|
31
|
-
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
|
|
32
|
-
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
|
|
33
|
-
}
|
|
34
|
-
if (prev && next && prev.pct !== next.pct) {
|
|
35
|
-
const t = (pct - prev.pct) / (next.pct - prev.pct);
|
|
36
|
-
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
|
|
37
|
-
} else if (prev) {
|
|
38
|
-
result[prop] = Math.round(prev.val);
|
|
39
|
-
} else if (next) {
|
|
40
|
-
result[prop] = Math.round(next.val);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return result;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function readRuntimeKeyframeValues(
|
|
47
|
-
iframe: HTMLIFrameElement | null,
|
|
48
|
-
sel: DomEditSelection,
|
|
49
|
-
keyframes: GsapPercentageKeyframe[],
|
|
50
|
-
): Record<string, number> {
|
|
51
|
-
if (!iframe?.contentWindow) return {};
|
|
52
|
-
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
|
53
|
-
try {
|
|
54
|
-
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
|
55
|
-
} catch {
|
|
56
|
-
return {};
|
|
57
|
-
}
|
|
58
|
-
if (!gsap?.getProperty) return {};
|
|
59
|
-
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
|
60
|
-
if (!selector) return {};
|
|
61
|
-
let doc: Document | null = null;
|
|
62
|
-
try {
|
|
63
|
-
doc = iframe.contentDocument;
|
|
64
|
-
} catch {
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
|
-
const element = doc?.querySelector(selector);
|
|
68
|
-
if (!element) return {};
|
|
69
|
-
const allProps = new Set<string>();
|
|
70
|
-
for (const kf of keyframes) {
|
|
71
|
-
for (const p of Object.keys(kf.properties)) {
|
|
72
|
-
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const result: Record<string, number> = {};
|
|
76
|
-
for (const prop of allProps) {
|
|
77
|
-
const val = Number(gsap.getProperty(element, prop));
|
|
78
|
-
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
|
79
|
-
}
|
|
80
|
-
return result;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface DomEditSessionSlice {
|
|
15
|
+
interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
84
16
|
domEditSelection: DomEditSelection | null;
|
|
85
17
|
selectedGsapAnimations: GsapAnimation[];
|
|
86
|
-
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
|
87
|
-
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
|
88
|
-
handleGsapConvertToKeyframes: (animId: string) => void;
|
|
89
|
-
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
|
|
90
|
-
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
91
|
-
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
|
92
18
|
}
|
|
93
19
|
|
|
94
20
|
interface TimelineToolbarProps {
|
|
@@ -97,15 +23,20 @@ interface TimelineToolbarProps {
|
|
|
97
23
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
98
24
|
}
|
|
99
25
|
|
|
100
|
-
// fallow-ignore-next-line complexity
|
|
101
26
|
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
102
27
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
28
|
+
const sessionRef = useRef(session);
|
|
29
|
+
sessionRef.current = session;
|
|
30
|
+
|
|
31
|
+
const onToggle = useEnableKeyframes(
|
|
32
|
+
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
|
|
33
|
+
);
|
|
34
|
+
|
|
103
35
|
if (!session) return { state: "none" as const, onToggle: undefined };
|
|
104
36
|
|
|
105
37
|
const sel = session.domEditSelection;
|
|
106
38
|
const anims = session.selectedGsapAnimations;
|
|
107
39
|
const kfAnim = anims.find((a) => a.keyframes);
|
|
108
|
-
const flatAnim = anims.find((a) => !a.keyframes);
|
|
109
40
|
|
|
110
41
|
let state: "active" | "inactive" | "none" = "none";
|
|
111
42
|
if (kfAnim?.keyframes && sel) {
|
|
@@ -120,48 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
120
51
|
: "inactive";
|
|
121
52
|
}
|
|
122
53
|
|
|
123
|
-
|
|
124
|
-
const onToggle = sel
|
|
125
|
-
? async () => {
|
|
126
|
-
const t = usePlayerStore.getState().currentTime;
|
|
127
|
-
if (kfAnim?.keyframes) {
|
|
128
|
-
if (kfAnim.hasUnresolvedKeyframes) {
|
|
129
|
-
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
|
|
130
|
-
}
|
|
131
|
-
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
132
|
-
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
133
|
-
const pct =
|
|
134
|
-
elDuration > 0
|
|
135
|
-
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
|
136
|
-
: 0;
|
|
137
|
-
const existing = kfAnim.keyframes.keyframes.find(
|
|
138
|
-
(k) => Math.abs(k.percentage - pct) <= 1,
|
|
139
|
-
);
|
|
140
|
-
if (existing) {
|
|
141
|
-
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
142
|
-
} else {
|
|
143
|
-
const runtimeValues = readRuntimeKeyframeValues(
|
|
144
|
-
session.previewIframeRef?.current ?? null,
|
|
145
|
-
sel,
|
|
146
|
-
kfAnim.keyframes.keyframes,
|
|
147
|
-
);
|
|
148
|
-
const values =
|
|
149
|
-
Object.keys(runtimeValues).length > 0
|
|
150
|
-
? runtimeValues
|
|
151
|
-
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
|
|
152
|
-
for (const [prop, val] of Object.entries(values)) {
|
|
153
|
-
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} else if (flatAnim) {
|
|
157
|
-
session.handleGsapConvertToKeyframes(flatAnim.id);
|
|
158
|
-
} else {
|
|
159
|
-
session.handleGsapAddAnimation("to");
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
: undefined;
|
|
163
|
-
|
|
164
|
-
return { state, onToggle };
|
|
54
|
+
return { state, onToggle: sel ? onToggle : undefined };
|
|
165
55
|
}
|
|
166
56
|
|
|
167
57
|
export function TimelineToolbar({
|
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
} from "./gsapAnimationConstants";
|
|
18
18
|
import { buildTweenSummary } from "./gsapAnimationHelpers";
|
|
19
19
|
import { EaseCurveSection } from "./EaseCurveSection";
|
|
20
|
+
import { ArcPathControls } from "./ArcPathControls";
|
|
21
|
+
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
|
|
22
|
+
import { P } from "./panelTokens";
|
|
20
23
|
const BOOLEAN_PROPS = new Set(["visibility"]);
|
|
21
24
|
const STRING_PROPS = new Set(["filter", "clipPath"]);
|
|
22
25
|
|
|
@@ -97,11 +100,18 @@ function PropertyRow({
|
|
|
97
100
|
<button
|
|
98
101
|
type="button"
|
|
99
102
|
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
|
|
100
|
-
className={`flex-shrink-0
|
|
103
|
+
className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
|
|
104
|
+
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
|
|
101
105
|
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
|
102
106
|
>
|
|
103
107
|
<span
|
|
104
|
-
className=
|
|
108
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
|
109
|
+
style={{
|
|
110
|
+
width: 12,
|
|
111
|
+
height: 12,
|
|
112
|
+
background: isVisible ? P.white : P.textMuted,
|
|
113
|
+
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
|
|
114
|
+
}}
|
|
105
115
|
/>
|
|
106
116
|
</button>
|
|
107
117
|
</div>
|
|
@@ -241,6 +251,15 @@ interface AnimationCardProps {
|
|
|
241
251
|
onRemoveFromProperty?: (animationId: string, property: string) => void;
|
|
242
252
|
onLivePreview?: (property: string, value: number | string) => void;
|
|
243
253
|
onLivePreviewEnd?: () => void;
|
|
254
|
+
onSetArcPath?: (
|
|
255
|
+
animationId: string,
|
|
256
|
+
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
|
257
|
+
) => void;
|
|
258
|
+
onUpdateArcSegment?: (
|
|
259
|
+
animationId: string,
|
|
260
|
+
segmentIndex: number,
|
|
261
|
+
update: Partial<ArcPathSegment>,
|
|
262
|
+
) => void;
|
|
244
263
|
}
|
|
245
264
|
|
|
246
265
|
// fallow-ignore-next-line complexity
|
|
@@ -257,6 +276,8 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
257
276
|
onRemoveFromProperty,
|
|
258
277
|
onLivePreview,
|
|
259
278
|
onLivePreviewEnd,
|
|
279
|
+
onSetArcPath,
|
|
280
|
+
onUpdateArcSegment,
|
|
260
281
|
}: AnimationCardProps) {
|
|
261
282
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
262
283
|
const [addingProp, setAddingProp] = useState(false);
|
|
@@ -329,7 +350,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
329
350
|
const [copied, setCopied] = useState(false);
|
|
330
351
|
|
|
331
352
|
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
|
|
332
|
-
const easeName = animation.ease ?? "none";
|
|
353
|
+
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
|
|
333
354
|
const easeLabel = easeName.startsWith("custom(")
|
|
334
355
|
? "Custom curve"
|
|
335
356
|
: (EASE_LABELS[easeName] ?? easeName);
|
|
@@ -348,7 +369,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
348
369
|
className="flex w-full items-center gap-2 py-1.5"
|
|
349
370
|
>
|
|
350
371
|
<span
|
|
351
|
-
className="rounded bg-
|
|
372
|
+
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
|
|
352
373
|
title={METHOD_TOOLTIPS[animation.method]}
|
|
353
374
|
>
|
|
354
375
|
{methodLabel}
|
|
@@ -420,13 +441,13 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
420
441
|
<>
|
|
421
442
|
<SelectField
|
|
422
443
|
label="Speed"
|
|
423
|
-
value={
|
|
424
|
-
animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
|
|
425
|
-
}
|
|
444
|
+
value={easeName.startsWith("custom(") ? "custom" : easeName}
|
|
426
445
|
options={[...SUPPORTED_EASES, "custom"]}
|
|
427
446
|
onChange={(next) => {
|
|
428
447
|
if (next === "custom") {
|
|
429
|
-
const points = controlPointsForGsapEase(
|
|
448
|
+
const points = controlPointsForGsapEase(
|
|
449
|
+
easeName !== "none" ? easeName : "power2.out",
|
|
450
|
+
);
|
|
430
451
|
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
|
|
431
452
|
onUpdateMeta(animation.id, { ease: `custom(${path})` });
|
|
432
453
|
} else {
|
|
@@ -435,7 +456,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
435
456
|
}}
|
|
436
457
|
/>
|
|
437
458
|
<EaseCurveSection
|
|
438
|
-
ease={
|
|
459
|
+
ease={easeName}
|
|
439
460
|
duration={animation.duration}
|
|
440
461
|
onCustomEaseCommit={(customEase) =>
|
|
441
462
|
onUpdateMeta(animation.id, { ease: customEase })
|
|
@@ -477,7 +498,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
477
498
|
)}
|
|
478
499
|
|
|
479
500
|
{animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (
|
|
480
|
-
<p className="text-[9px] font-semibold uppercase tracking-wider text-
|
|
501
|
+
<p className="text-[9px] font-semibold uppercase tracking-wider text-panel-accent/70">
|
|
481
502
|
To
|
|
482
503
|
</p>
|
|
483
504
|
)}
|
|
@@ -500,6 +521,39 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
500
521
|
</div>
|
|
501
522
|
)}
|
|
502
523
|
|
|
524
|
+
{onSetArcPath &&
|
|
525
|
+
(animation.properties.x != null ||
|
|
526
|
+
animation.properties.y != null ||
|
|
527
|
+
animation.keyframes) && (
|
|
528
|
+
<div className="border-t border-neutral-800 pt-3">
|
|
529
|
+
<ArcPathControls
|
|
530
|
+
arcPath={
|
|
531
|
+
animation.arcPath ?? { enabled: false, autoRotate: false, segments: [] }
|
|
532
|
+
}
|
|
533
|
+
segmentCount={Math.max(
|
|
534
|
+
animation.properties.x != null || animation.properties.y != null ? 1 : 0,
|
|
535
|
+
(animation.keyframes?.keyframes?.length ?? 0) - 1,
|
|
536
|
+
)}
|
|
537
|
+
onToggle={(enabled) =>
|
|
538
|
+
onSetArcPath(animation.id, {
|
|
539
|
+
enabled,
|
|
540
|
+
segments: animation.arcPath?.segments,
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
onUpdateSegment={(index, update) =>
|
|
544
|
+
onUpdateArcSegment?.(animation.id, index, update)
|
|
545
|
+
}
|
|
546
|
+
onToggleAutoRotate={(autoRotate) =>
|
|
547
|
+
onSetArcPath(animation.id, {
|
|
548
|
+
enabled: true,
|
|
549
|
+
autoRotate,
|
|
550
|
+
segments: animation.arcPath?.segments,
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
/>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
|
|
503
557
|
<div className="flex items-center gap-2 pt-1">
|
|
504
558
|
<AddPropertyTrigger
|
|
505
559
|
adding={addingProp}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { memo, useCallback } from "react";
|
|
2
|
+
import type { ArcPathConfig, ArcPathSegment } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { SliderControl } from "./propertyPanelPrimitives";
|
|
4
|
+
import { LABEL } from "./propertyPanelHelpers";
|
|
5
|
+
import { P } from "./panelTokens";
|
|
6
|
+
|
|
7
|
+
interface ArcPathControlsProps {
|
|
8
|
+
arcPath: ArcPathConfig;
|
|
9
|
+
segmentCount: number;
|
|
10
|
+
onToggle: (enabled: boolean) => void;
|
|
11
|
+
onUpdateSegment: (index: number, update: Partial<ArcPathSegment>) => void;
|
|
12
|
+
onToggleAutoRotate: (autoRotate: boolean) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ArcPathControls = memo(function ArcPathControls({
|
|
17
|
+
arcPath,
|
|
18
|
+
segmentCount,
|
|
19
|
+
onToggle,
|
|
20
|
+
onUpdateSegment,
|
|
21
|
+
onToggleAutoRotate,
|
|
22
|
+
disabled,
|
|
23
|
+
}: ArcPathControlsProps) {
|
|
24
|
+
const handleToggle = useCallback(() => {
|
|
25
|
+
onToggle(!arcPath.enabled);
|
|
26
|
+
}, [arcPath.enabled, onToggle]);
|
|
27
|
+
|
|
28
|
+
const handleAutoRotate = useCallback(() => {
|
|
29
|
+
onToggleAutoRotate(!arcPath.autoRotate);
|
|
30
|
+
}, [arcPath.autoRotate, onToggleAutoRotate]);
|
|
31
|
+
|
|
32
|
+
if (segmentCount < 1) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-3 py-2">
|
|
35
|
+
<p className="text-[11px] text-neutral-500">
|
|
36
|
+
Add at least 2 position keyframes to enable arc motion.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-3">
|
|
44
|
+
<div className="flex items-center justify-between">
|
|
45
|
+
<span className={LABEL}>Arc Motion</span>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={handleToggle}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
className="relative rounded-full transition-all duration-150"
|
|
51
|
+
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
|
|
52
|
+
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
|
53
|
+
>
|
|
54
|
+
<span
|
|
55
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
|
56
|
+
style={{
|
|
57
|
+
width: 12,
|
|
58
|
+
height: 12,
|
|
59
|
+
background: arcPath.enabled ? P.white : P.textMuted,
|
|
60
|
+
transform: arcPath.enabled ? "translateX(14px)" : "translateX(2px)",
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{arcPath.enabled && (
|
|
67
|
+
<>
|
|
68
|
+
<div className="flex items-center justify-between">
|
|
69
|
+
<span className={LABEL}>Auto-Rotate</span>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={handleAutoRotate}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
className="relative rounded-full transition-all duration-150"
|
|
75
|
+
style={{
|
|
76
|
+
width: 28,
|
|
77
|
+
height: 16,
|
|
78
|
+
background: arcPath.autoRotate ? P.accent : "#27272A",
|
|
79
|
+
}}
|
|
80
|
+
title={
|
|
81
|
+
arcPath.autoRotate
|
|
82
|
+
? "Disable auto-rotate along path"
|
|
83
|
+
: "Rotate element to follow path tangent"
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
<span
|
|
87
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
|
88
|
+
style={{
|
|
89
|
+
width: 12,
|
|
90
|
+
height: 12,
|
|
91
|
+
background: arcPath.autoRotate ? P.white : P.textMuted,
|
|
92
|
+
transform: arcPath.autoRotate ? "translateX(14px)" : "translateX(2px)",
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{arcPath.segments.map((seg, i) => (
|
|
99
|
+
<div key={i} className="grid min-w-0 gap-1.5">
|
|
100
|
+
<div className="flex items-center justify-between">
|
|
101
|
+
<span className={LABEL}>
|
|
102
|
+
{segmentCount === 1 ? "Curviness" : `Segment ${i + 1}`}
|
|
103
|
+
</span>
|
|
104
|
+
{seg.cp1 && seg.cp2 && (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onUpdateSegment(i, { cp1: undefined, cp2: undefined })}
|
|
108
|
+
className="text-[9px] font-medium text-neutral-500 transition-colors hover:text-neutral-300"
|
|
109
|
+
title="Reset to auto-generated control points"
|
|
110
|
+
>
|
|
111
|
+
Reset
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
<SliderControl
|
|
116
|
+
value={seg.curviness}
|
|
117
|
+
min={0}
|
|
118
|
+
max={3}
|
|
119
|
+
step={0.1}
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
displayValue={seg.curviness.toFixed(1)}
|
|
122
|
+
formatDisplayValue={(v) => v.toFixed(1)}
|
|
123
|
+
onCommit={(v) => onUpdateSegment(i, { curviness: v })}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { MetricField } from "./propertyPanelPrimitives";
|
|
3
|
+
import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
4
|
+
|
|
5
|
+
type Corner = "tl" | "tr" | "br" | "bl";
|
|
6
|
+
|
|
7
|
+
interface BorderRadiusEditorProps {
|
|
8
|
+
tl: number;
|
|
9
|
+
tr: number;
|
|
10
|
+
br: number;
|
|
11
|
+
bl: number;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onCommit: (corner: Corner | "all", value: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PREVIEW_W = 72;
|
|
17
|
+
const PREVIEW_H = 52;
|
|
18
|
+
const MAX_RADIUS = 26;
|
|
19
|
+
|
|
20
|
+
function clampRadius(v: number): number {
|
|
21
|
+
return Math.max(0, Math.min(MAX_RADIUS, v));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scaleRadius(v: number, maxPx: number): number {
|
|
25
|
+
if (maxPx <= 0) return 0;
|
|
26
|
+
return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function BorderRadiusEditor({
|
|
30
|
+
tl,
|
|
31
|
+
tr,
|
|
32
|
+
br,
|
|
33
|
+
bl,
|
|
34
|
+
disabled,
|
|
35
|
+
onCommit,
|
|
36
|
+
}: BorderRadiusEditorProps) {
|
|
37
|
+
const uniform = tl === tr && tr === br && br === bl;
|
|
38
|
+
const [linked, setLinked] = useState(uniform);
|
|
39
|
+
|
|
40
|
+
const maxVal = Math.max(tl, tr, br, bl, 1);
|
|
41
|
+
const sTL = scaleRadius(tl, maxVal);
|
|
42
|
+
const sTR = scaleRadius(tr, maxVal);
|
|
43
|
+
const sBR = scaleRadius(br, maxVal);
|
|
44
|
+
const sBL = scaleRadius(bl, maxVal);
|
|
45
|
+
|
|
46
|
+
const handleCornerCommit = useCallback(
|
|
47
|
+
(corner: Corner, raw: string) => {
|
|
48
|
+
const v = parseNumericValue(raw) ?? 0;
|
|
49
|
+
if (linked) {
|
|
50
|
+
onCommit("all", v);
|
|
51
|
+
} else {
|
|
52
|
+
onCommit(corner, v);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[linked, onCommit],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const handleToggleLinked = useCallback(() => {
|
|
59
|
+
if (!linked && !uniform) {
|
|
60
|
+
onCommit("all", tl);
|
|
61
|
+
}
|
|
62
|
+
setLinked((l) => !l);
|
|
63
|
+
}, [linked, uniform, tl, onCommit]);
|
|
64
|
+
|
|
65
|
+
const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-3">
|
|
69
|
+
<div className="flex items-center gap-3">
|
|
70
|
+
<svg
|
|
71
|
+
width={PREVIEW_W}
|
|
72
|
+
height={PREVIEW_H}
|
|
73
|
+
viewBox={`0 0 ${PREVIEW_W} ${PREVIEW_H}`}
|
|
74
|
+
className="flex-shrink-0"
|
|
75
|
+
>
|
|
76
|
+
<path
|
|
77
|
+
d={path}
|
|
78
|
+
fill="rgba(255,255,255,0.06)"
|
|
79
|
+
stroke="rgba(255,255,255,0.24)"
|
|
80
|
+
strokeWidth={1.5}
|
|
81
|
+
/>
|
|
82
|
+
<circle
|
|
83
|
+
cx={sTL}
|
|
84
|
+
cy={sTL}
|
|
85
|
+
r={3}
|
|
86
|
+
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
87
|
+
className="cursor-pointer"
|
|
88
|
+
/>
|
|
89
|
+
<circle
|
|
90
|
+
cx={PREVIEW_W - sTR}
|
|
91
|
+
cy={sTR}
|
|
92
|
+
r={3}
|
|
93
|
+
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
94
|
+
className="cursor-pointer"
|
|
95
|
+
/>
|
|
96
|
+
<circle
|
|
97
|
+
cx={PREVIEW_W - sBR}
|
|
98
|
+
cy={PREVIEW_H - sBR}
|
|
99
|
+
r={3}
|
|
100
|
+
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
101
|
+
className="cursor-pointer"
|
|
102
|
+
/>
|
|
103
|
+
<circle
|
|
104
|
+
cx={sBL}
|
|
105
|
+
cy={PREVIEW_H - sBL}
|
|
106
|
+
r={3}
|
|
107
|
+
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
108
|
+
className="cursor-pointer"
|
|
109
|
+
/>
|
|
110
|
+
</svg>
|
|
111
|
+
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
|
115
|
+
onClick={handleToggleLinked}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
title={linked ? "Unlink corners" : "Link all corners"}
|
|
118
|
+
>
|
|
119
|
+
{linked ? (
|
|
120
|
+
<svg
|
|
121
|
+
width={14}
|
|
122
|
+
height={14}
|
|
123
|
+
viewBox="0 0 16 16"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
strokeWidth={1.5}
|
|
127
|
+
>
|
|
128
|
+
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2M5 8h6" />
|
|
129
|
+
</svg>
|
|
130
|
+
) : (
|
|
131
|
+
<svg
|
|
132
|
+
width={14}
|
|
133
|
+
height={14}
|
|
134
|
+
viewBox="0 0 16 16"
|
|
135
|
+
fill="none"
|
|
136
|
+
stroke="currentColor"
|
|
137
|
+
strokeWidth={1.5}
|
|
138
|
+
>
|
|
139
|
+
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2" />
|
|
140
|
+
</svg>
|
|
141
|
+
)}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{linked ? (
|
|
146
|
+
<MetricField
|
|
147
|
+
label="All"
|
|
148
|
+
value={formatNumericValue(tl)}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
liveCommit
|
|
151
|
+
onCommit={(next) => handleCornerCommit("tl", next)}
|
|
152
|
+
/>
|
|
153
|
+
) : (
|
|
154
|
+
<div className={RESPONSIVE_GRID}>
|
|
155
|
+
<MetricField
|
|
156
|
+
label="TL"
|
|
157
|
+
value={formatNumericValue(tl)}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
liveCommit
|
|
160
|
+
onCommit={(next) => handleCornerCommit("tl", next)}
|
|
161
|
+
/>
|
|
162
|
+
<MetricField
|
|
163
|
+
label="TR"
|
|
164
|
+
value={formatNumericValue(tr)}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
liveCommit
|
|
167
|
+
onCommit={(next) => handleCornerCommit("tr", next)}
|
|
168
|
+
/>
|
|
169
|
+
<MetricField
|
|
170
|
+
label="BL"
|
|
171
|
+
value={formatNumericValue(bl)}
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
liveCommit
|
|
174
|
+
onCommit={(next) => handleCornerCommit("bl", next)}
|
|
175
|
+
/>
|
|
176
|
+
<MetricField
|
|
177
|
+
label="BR"
|
|
178
|
+
value={formatNumericValue(br)}
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
liveCommit
|
|
181
|
+
onCommit={(next) => handleCornerCommit("br", next)}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildRoundedRectPath(
|
|
190
|
+
w: number,
|
|
191
|
+
h: number,
|
|
192
|
+
tl: number,
|
|
193
|
+
tr: number,
|
|
194
|
+
br: number,
|
|
195
|
+
bl: number,
|
|
196
|
+
): string {
|
|
197
|
+
return [
|
|
198
|
+
`M ${tl} 0`,
|
|
199
|
+
`L ${w - tr} 0`,
|
|
200
|
+
`Q ${w} 0 ${w} ${tr}`,
|
|
201
|
+
`L ${w} ${h - br}`,
|
|
202
|
+
`Q ${w} ${h} ${w - br} ${h}`,
|
|
203
|
+
`L ${bl} ${h}`,
|
|
204
|
+
`Q 0 ${h} 0 ${h - bl}`,
|
|
205
|
+
`L 0 ${tl}`,
|
|
206
|
+
`Q 0 0 ${tl} 0`,
|
|
207
|
+
"Z",
|
|
208
|
+
].join(" ");
|
|
209
|
+
}
|