@hyperframes/studio 0.7.79 → 0.7.80
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-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
- package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +652 -465
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/MotionPathOverlay.tsx +54 -19
- package/src/components/editor/PropertyPanel.tsx +4 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +4 -12
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useGsapTweenCache.ts +40 -34
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
- package/src/player/store/keyframeSlice.ts +15 -5
- package/dist/assets/index-gGVKuFg5.css +0 -1
|
@@ -10,6 +10,7 @@ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
|
|
|
10
10
|
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
|
|
11
11
|
import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
|
|
12
12
|
import { roundToCenti } from "../../utils/rounding";
|
|
13
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
13
14
|
|
|
14
15
|
export { MiniCurveSvg } from "./easeCurveSvg";
|
|
15
16
|
|
|
@@ -320,19 +321,36 @@ function EaseParameterField({
|
|
|
320
321
|
return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* How long an optimistically painted ease may outlive its commit. Long enough
|
|
326
|
+
* for a normal write-reparse-rerender round trip, short enough that a dropped
|
|
327
|
+
* write self-corrects while the author is still looking at the panel.
|
|
328
|
+
*/
|
|
329
|
+
const PENDING_EASE_TIMEOUT_MS = 2000;
|
|
330
|
+
|
|
323
331
|
export function EaseCurveSection({
|
|
324
332
|
ease,
|
|
325
333
|
onCustomEaseCommit,
|
|
334
|
+
collidingAnimationTargets,
|
|
326
335
|
}: {
|
|
327
336
|
ease: string;
|
|
328
337
|
onCustomEaseCommit: (ease: string) => void;
|
|
338
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
329
339
|
}) {
|
|
330
|
-
|
|
340
|
+
// The ease this section painted optimistically, still waiting for its commit
|
|
341
|
+
// to round-trip back through the `ease` prop.
|
|
342
|
+
const [pendingEase, setPendingEase] = useState<string | null>(null);
|
|
343
|
+
// Every value committed and not yet seen coming back, oldest first. It takes
|
|
344
|
+
// the whole queue, not just the latest, to tell an older commit echoing back
|
|
345
|
+
// apart from an edit made somewhere else.
|
|
346
|
+
const inFlightEasesRef = useRef<string[]>([]);
|
|
347
|
+
const displayedEase = pendingEase ?? ease;
|
|
348
|
+
const springBounce = parseSpringBounce(displayedEase);
|
|
331
349
|
const isSpring = springBounce !== null;
|
|
332
|
-
const wiggleConfig = parseWiggleEase(
|
|
350
|
+
const wiggleConfig = parseWiggleEase(displayedEase);
|
|
333
351
|
const isWiggle = wiggleConfig !== null;
|
|
334
352
|
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
|
|
335
|
-
const curve = resolveEditableCurve(
|
|
353
|
+
const curve = resolveEditableCurve(displayedEase, springBounce);
|
|
336
354
|
|
|
337
355
|
const [draft, setDraft] = useState<Pts | null>(null);
|
|
338
356
|
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
|
|
@@ -346,8 +364,43 @@ export function EaseCurveSection({
|
|
|
346
364
|
// `ease` changes, `curve` already equals the draft, so the handoff is seamless.
|
|
347
365
|
useEffect(() => {
|
|
348
366
|
setDraft(null);
|
|
367
|
+
const inFlight = inFlightEasesRef.current;
|
|
368
|
+
const landed = inFlight.indexOf(ease);
|
|
369
|
+
if (landed < 0) {
|
|
370
|
+
// A value this section never sent: someone else edited the ease, so the
|
|
371
|
+
// real value wins over anything optimistic still on screen.
|
|
372
|
+
inFlight.length = 0;
|
|
373
|
+
setPendingEase(null);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// One of this section's own commits came back. Everything sent before it
|
|
377
|
+
// is settled with it, but a NEWER commit may still be in flight, and
|
|
378
|
+
// repainting this older value while waiting for that one is the
|
|
379
|
+
// wiggle-then-spring-then-wiggle flicker of a fast double switch.
|
|
380
|
+
inFlight.splice(0, landed + 1);
|
|
381
|
+
if (inFlight.length === 0) setPendingEase(null);
|
|
349
382
|
}, [ease]);
|
|
350
383
|
|
|
384
|
+
// A commit is fire-and-forget, so a write that is rejected or lands as a
|
|
385
|
+
// no-op never changes `ease`, and the optimistic value would sit on screen
|
|
386
|
+
// claiming a curve the composition does not have. Nothing downstream reports
|
|
387
|
+
// that failure, so the display is time-bounded instead: fall back to the
|
|
388
|
+
// committed truth when the round trip does not arrive.
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (pendingEase === null) return;
|
|
391
|
+
const timer = setTimeout(() => {
|
|
392
|
+
inFlightEasesRef.current.length = 0;
|
|
393
|
+
setPendingEase(null);
|
|
394
|
+
}, PENDING_EASE_TIMEOUT_MS);
|
|
395
|
+
return () => clearTimeout(timer);
|
|
396
|
+
}, [pendingEase]);
|
|
397
|
+
|
|
398
|
+
const commitEase = (nextEase: string) => {
|
|
399
|
+
inFlightEasesRef.current.push(nextEase);
|
|
400
|
+
setPendingEase(nextEase);
|
|
401
|
+
onCustomEaseCommit(nextEase);
|
|
402
|
+
};
|
|
403
|
+
|
|
351
404
|
const activeTuple = draft ?? curve;
|
|
352
405
|
const displayTuple = activeTuple ?? DEFAULT_CURVE;
|
|
353
406
|
const [x1, y1, x2, y2] = displayTuple;
|
|
@@ -358,8 +411,12 @@ export function EaseCurveSection({
|
|
|
358
411
|
const a1 = { x: xToSvg(1), y: yToSvg(1) };
|
|
359
412
|
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
|
|
360
413
|
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
|
|
361
|
-
|
|
362
|
-
|
|
414
|
+
// Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch
|
|
415
|
+
// paints immediately instead of waiting for the committed prop to come back.
|
|
416
|
+
const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple);
|
|
417
|
+
const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold";
|
|
418
|
+
// `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat
|
|
419
|
+
// step) but has no editable control points to drag.
|
|
363
420
|
const showHandles = curve !== null && !isSpring && !isWiggle;
|
|
364
421
|
|
|
365
422
|
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
|
|
@@ -394,10 +451,9 @@ export function EaseCurveSection({
|
|
|
394
451
|
if (!draggingRef.current || !draft) return;
|
|
395
452
|
draggingRef.current = null;
|
|
396
453
|
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
queueMicrotask(() => setDraft(null));
|
|
454
|
+
// Commit only — the draft stays on screen and is cleared by the effect above
|
|
455
|
+
// once the committed `ease` prop comes back, so the curve never flickers.
|
|
456
|
+
commitEase(`custom(${path})`);
|
|
401
457
|
};
|
|
402
458
|
|
|
403
459
|
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
|
|
@@ -406,20 +462,26 @@ export function EaseCurveSection({
|
|
|
406
462
|
event.preventDefault();
|
|
407
463
|
event.stopPropagation();
|
|
408
464
|
setDraft(next);
|
|
409
|
-
|
|
410
|
-
|
|
465
|
+
// Same no-flicker contract as the pointer path: commit and let the effect
|
|
466
|
+
// clear the draft, rather than dropping it on the next microtask.
|
|
467
|
+
commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
|
|
411
468
|
};
|
|
412
469
|
|
|
413
470
|
const top = yToSvg(1);
|
|
414
471
|
const bottom = yToSvg(0);
|
|
415
472
|
const left = xToSvg(0);
|
|
416
473
|
const right = xToSvg(1);
|
|
417
|
-
const label = resolveEditorLabel(
|
|
474
|
+
const label = resolveEditorLabel(displayedEase, springBounce, isWiggle);
|
|
418
475
|
|
|
419
476
|
return (
|
|
420
477
|
<div className="rounded-lg bg-neutral-900/50 p-2">
|
|
421
|
-
<EaseTypeDropdown kind={mode} ease={
|
|
422
|
-
|
|
478
|
+
<EaseTypeDropdown kind={mode} ease={displayedEase} label={label} onSelect={commitEase} />
|
|
479
|
+
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
|
|
480
|
+
<p className="mb-1 text-[9px] text-neutral-500">
|
|
481
|
+
Applies to {collidingAnimationTargets.length} animations
|
|
482
|
+
</p>
|
|
483
|
+
)}
|
|
484
|
+
<EaseModeToggle mode={mode} onCommit={commitEase} />
|
|
423
485
|
<span className="sr-only" aria-live="polite">
|
|
424
486
|
{MODE_LABELS[mode]} ease editor selected
|
|
425
487
|
</span>
|
|
@@ -560,7 +622,7 @@ export function EaseCurveSection({
|
|
|
560
622
|
springBounce={springBounce}
|
|
561
623
|
wiggleConfig={wiggleConfig}
|
|
562
624
|
tuple={displayTuple}
|
|
563
|
-
onCommit={
|
|
625
|
+
onCommit={commitEase}
|
|
564
626
|
/>
|
|
565
627
|
</>
|
|
566
628
|
) : (
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
|
+
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
|
8
|
+
import { usePlayerStore } from "../../player";
|
|
9
|
+
import { GsapAnimationSection } from "./GsapAnimationSection";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
vi.mock("./AnimationCard", () => ({
|
|
14
|
+
AnimationCard: ({
|
|
15
|
+
animation,
|
|
16
|
+
focusedSegment,
|
|
17
|
+
onFocusSegmentConsumed,
|
|
18
|
+
}: {
|
|
19
|
+
animation: GsapAnimation;
|
|
20
|
+
focusedSegment: { tweenPercentage: number } | null;
|
|
21
|
+
onFocusSegmentConsumed: () => void;
|
|
22
|
+
}) => (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
data-testid={`animation-${animation.id}`}
|
|
26
|
+
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
|
|
27
|
+
// Mirrors the real card: the consume callback only fires from the effect
|
|
28
|
+
// that runs when this card actually received a focusedSegment.
|
|
29
|
+
onClick={() => focusedSegment && onFocusSegmentConsumed()}
|
|
30
|
+
/>
|
|
31
|
+
),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
document.body.innerHTML = "";
|
|
38
|
+
usePlayerStore.getState().reset();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const sharedAnimation: GsapAnimation = {
|
|
42
|
+
id: "shared-animation",
|
|
43
|
+
targetSelector: ".shared",
|
|
44
|
+
method: "to",
|
|
45
|
+
position: 0,
|
|
46
|
+
properties: { x: 100 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const requiredCallbacks = {
|
|
50
|
+
onAddAnimation: vi.fn(),
|
|
51
|
+
onUpdateProperty: vi.fn(),
|
|
52
|
+
onUpdateMeta: vi.fn(),
|
|
53
|
+
onDeleteAnimation: vi.fn(),
|
|
54
|
+
onAddProperty: vi.fn(),
|
|
55
|
+
onRemoveProperty: vi.fn(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function renderSection(elementId: string) {
|
|
59
|
+
const host = document.createElement("div");
|
|
60
|
+
document.body.append(host);
|
|
61
|
+
const root = createRoot(host);
|
|
62
|
+
const render = (nextElementId: string) => {
|
|
63
|
+
act(() => {
|
|
64
|
+
root.render(
|
|
65
|
+
<DesignPanelInputProvider section="test">
|
|
66
|
+
<GsapAnimationSection
|
|
67
|
+
{...requiredCallbacks}
|
|
68
|
+
elementId={nextElementId}
|
|
69
|
+
animations={[sharedAnimation]}
|
|
70
|
+
/>
|
|
71
|
+
</DesignPanelInputProvider>,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
render(elementId);
|
|
76
|
+
return { host, root, render };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("GsapAnimationSection", () => {
|
|
80
|
+
it("consumes a shared animation id only for the focused element", () => {
|
|
81
|
+
usePlayerStore.getState().setFocusedEaseSegment({
|
|
82
|
+
elementId: "index.html#second",
|
|
83
|
+
animationId: sharedAnimation.id,
|
|
84
|
+
tweenPercentage: 50,
|
|
85
|
+
});
|
|
86
|
+
const view = renderSection("index.html#first");
|
|
87
|
+
const card = view.host.querySelector<HTMLButtonElement>(
|
|
88
|
+
"[data-testid='animation-shared-animation']",
|
|
89
|
+
);
|
|
90
|
+
if (!card) throw new Error("expected animation card");
|
|
91
|
+
|
|
92
|
+
expect(card.dataset.focused).toBe("");
|
|
93
|
+
act(() => card.click());
|
|
94
|
+
expect(usePlayerStore.getState().focusedEaseSegment?.elementId).toBe("index.html#second");
|
|
95
|
+
|
|
96
|
+
view.render("index.html#second");
|
|
97
|
+
expect(card.dataset.focused).toBe("50");
|
|
98
|
+
act(() => card.click());
|
|
99
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
100
|
+
act(() => view.root.unmount());
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -13,6 +13,7 @@ import { usePlayerStore } from "../../player";
|
|
|
13
13
|
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
|
14
14
|
|
|
15
15
|
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
|
16
|
+
elementId: string;
|
|
16
17
|
animations: GsapAnimation[];
|
|
17
18
|
multipleTimelines?: boolean;
|
|
18
19
|
unsupportedTimelinePattern?: boolean;
|
|
@@ -21,6 +22,7 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
|
|
21
22
|
|
|
22
23
|
export const GsapAnimationSection = memo(function GsapAnimationSection({
|
|
23
24
|
animations,
|
|
25
|
+
elementId,
|
|
24
26
|
multipleTimelines,
|
|
25
27
|
unsupportedTimelinePattern,
|
|
26
28
|
onAddAnimation,
|
|
@@ -55,7 +57,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
|
|
55
57
|
animation={anim}
|
|
56
58
|
defaultExpanded={index === 0}
|
|
57
59
|
focusedSegment={
|
|
58
|
-
focusedEaseSegment?.
|
|
60
|
+
focusedEaseSegment?.elementId === elementId &&
|
|
61
|
+
focusedEaseSegment.animationId === anim.id
|
|
62
|
+
? focusedEaseSegment
|
|
63
|
+
: null
|
|
59
64
|
}
|
|
60
65
|
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
|
61
66
|
/>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import { EASE_LABELS } from "./gsapAnimationConstants";
|
|
3
3
|
import { EaseCurveSection } from "./EaseCurveSection";
|
|
4
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
4
5
|
|
|
5
6
|
// The full GSAP easing vocabulary offered by the "Set all…" bulk control —
|
|
6
7
|
// every standard family in in/out/inOut, so authors aren't limited to a curated
|
|
@@ -44,6 +45,7 @@ export function KeyframeEaseList({
|
|
|
44
45
|
keyframes,
|
|
45
46
|
globalEase,
|
|
46
47
|
expandedPct,
|
|
48
|
+
collidingAnimationTargets,
|
|
47
49
|
onToggle,
|
|
48
50
|
onEaseCommit,
|
|
49
51
|
onApplyAll,
|
|
@@ -51,6 +53,7 @@ export function KeyframeEaseList({
|
|
|
51
53
|
keyframes: GsapPercentageKeyframe[];
|
|
52
54
|
globalEase: string;
|
|
53
55
|
expandedPct: number | null;
|
|
56
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
54
57
|
onToggle: (pct: number | null) => void;
|
|
55
58
|
onEaseCommit: (pct: number, ease: string) => void;
|
|
56
59
|
/** Apply one ease to every segment at once (clears per-segment overrides). */
|
|
@@ -119,6 +122,7 @@ export function KeyframeEaseList({
|
|
|
119
122
|
<div className="px-2 pb-2">
|
|
120
123
|
<EaseCurveSection
|
|
121
124
|
ease={segEase}
|
|
125
|
+
collidingAnimationTargets={collidingAnimationTargets}
|
|
122
126
|
onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)}
|
|
123
127
|
/>
|
|
124
128
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
1
2
|
import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
3
|
import type { DomEditSelection } from "./domEditing";
|
|
3
4
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
KeyframeDiamondContextMenu,
|
|
12
13
|
type KeyframeDiamondContextMenuState,
|
|
13
14
|
} from "../../player/components/KeyframeDiamondContextMenu";
|
|
15
|
+
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
|
|
14
16
|
import {
|
|
15
17
|
commitAddKeyframe,
|
|
16
18
|
commitAddWaypoint,
|
|
@@ -95,15 +97,19 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
95
97
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
96
98
|
const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
|
|
97
99
|
const [hoverNode, setHoverNode] = useState<number | null>(null);
|
|
98
|
-
// Right-click context menu on a
|
|
99
|
-
// timeline keyframe diamond.
|
|
100
|
-
|
|
100
|
+
// Right-click context menu on a path node — same delete actions as the
|
|
101
|
+
// timeline keyframe diamond. The node it was opened on rides along, because
|
|
102
|
+
// which entries apply depends on whether it is a keyframe or a waypoint.
|
|
103
|
+
const [kfMenu, setKfMenu] = useState<{
|
|
104
|
+
state: KeyframeDiamondContextMenuState;
|
|
105
|
+
ref: MotionNodeRef;
|
|
106
|
+
} | null>(null);
|
|
101
107
|
// The keyframe % selected by clicking its node — highlighted, and the next drag
|
|
102
108
|
// modifies it rather than adding a keyframe.
|
|
103
109
|
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
|
|
104
110
|
const timelineElement = usePlayerStore((state) => {
|
|
105
111
|
if (!selection) return undefined;
|
|
106
|
-
const sourceScopedId =
|
|
112
|
+
const sourceScopedId = scopedElementKey(selection);
|
|
107
113
|
return state.elements.find(
|
|
108
114
|
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
|
|
109
115
|
);
|
|
@@ -435,21 +441,47 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
435
441
|
};
|
|
436
442
|
|
|
437
443
|
const elementId = selection?.id ?? null;
|
|
438
|
-
// Right-click
|
|
439
|
-
//
|
|
444
|
+
// Right-click any path node → the timeline's keyframe context menu (delete this
|
|
445
|
+
// one / delete all), so path nodes are removable in place. Waypoints open it
|
|
446
|
+
// too: returning early for them let the browser's own context menu open over
|
|
447
|
+
// the editor overlay, which is never what a right-click on a node should do.
|
|
440
448
|
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
|
|
441
|
-
if (
|
|
449
|
+
if (!animId || !elementId || !timelineElement) return;
|
|
442
450
|
e.preventDefault();
|
|
443
451
|
e.stopPropagation();
|
|
444
452
|
setKfMenu({
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
453
|
+
ref,
|
|
454
|
+
state: {
|
|
455
|
+
x: e.clientX,
|
|
456
|
+
y: e.clientY,
|
|
457
|
+
element: timelineElement,
|
|
458
|
+
elementId,
|
|
459
|
+
// A waypoint carries no percentage of its own: one tween-level ease owns
|
|
460
|
+
// every segment, and its index is the identity the delete acts on. The
|
|
461
|
+
// menu never reads this for a waypoint, because the only entry that
|
|
462
|
+
// would (Move to Playhead) is hidden below.
|
|
463
|
+
percentage: ref.type === "keyframe" ? ref.pct : 0,
|
|
464
|
+
tweenPercentage: ref.type === "keyframe" ? ref.pct : 0,
|
|
465
|
+
},
|
|
451
466
|
});
|
|
452
467
|
};
|
|
468
|
+
const menuRef = kfMenu?.ref;
|
|
469
|
+
// Deleting one node: by tween-% for a keyframe, by path index for a waypoint.
|
|
470
|
+
// A waypoint delete is offered on the same condition as the hover x badge,
|
|
471
|
+
// because removeMotionPathPointInScript refuses to drop an arc below two
|
|
472
|
+
// anchors and the entry would silently do nothing.
|
|
473
|
+
const onMenuDelete =
|
|
474
|
+
menuRef === undefined || !animId
|
|
475
|
+
? undefined
|
|
476
|
+
: menuRef.type === "keyframe"
|
|
477
|
+
? (_elId: string, keyframe: TimelineKeyframeTarget) => {
|
|
478
|
+
handleGsapRemoveKeyframe(animId, keyframe.percentage);
|
|
479
|
+
}
|
|
480
|
+
: removable
|
|
481
|
+
? () => {
|
|
482
|
+
void commitRemoveWaypoint(animId, menuRef.index, commitMutation);
|
|
483
|
+
}
|
|
484
|
+
: undefined;
|
|
453
485
|
|
|
454
486
|
return (
|
|
455
487
|
<>
|
|
@@ -531,14 +563,17 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
531
563
|
</svg>
|
|
532
564
|
{kfMenu && (
|
|
533
565
|
<KeyframeDiamondContextMenu
|
|
534
|
-
state={kfMenu}
|
|
566
|
+
state={kfMenu.state}
|
|
535
567
|
onClose={() => setKfMenu(null)}
|
|
536
|
-
onDelete={
|
|
537
|
-
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
|
538
|
-
}
|
|
568
|
+
onDelete={onMenuDelete}
|
|
539
569
|
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
|
540
|
-
|
|
541
|
-
|
|
570
|
+
// Retiming needs a percentage of this node's own, which a waypoint
|
|
571
|
+
// does not have.
|
|
572
|
+
onMoveToPlayhead={
|
|
573
|
+
kfMenu.ref.type === "keyframe"
|
|
574
|
+
? (_element, keyframe) =>
|
|
575
|
+
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
|
|
576
|
+
: undefined
|
|
542
577
|
}
|
|
543
578
|
/>
|
|
544
579
|
)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
1
2
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
3
|
import { Move } from "../../icons/SystemIcons";
|
|
3
4
|
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
|
@@ -101,6 +102,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
101
102
|
onUpdateArcSegment,
|
|
102
103
|
onUnroll,
|
|
103
104
|
onUpdateKeyframeEase,
|
|
105
|
+
onUpdateSegmentEase,
|
|
104
106
|
onSetAllKeyframeEases,
|
|
105
107
|
onAddKeyframe,
|
|
106
108
|
onRemoveKeyframe,
|
|
@@ -556,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
556
558
|
onAddGsapProperty &&
|
|
557
559
|
onAddGsapAnimation && (
|
|
558
560
|
<GsapAnimationSection
|
|
561
|
+
elementId={scopedElementKey(element)}
|
|
559
562
|
animations={gsapAnimations}
|
|
560
563
|
multipleTimelines={gsapMultipleTimelines}
|
|
561
564
|
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
|
@@ -573,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
573
576
|
onUnroll={onUnroll}
|
|
574
577
|
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
575
578
|
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
|
579
|
+
onUpdateSegmentEase={onUpdateSegmentEase}
|
|
576
580
|
/>
|
|
577
581
|
)}
|
|
578
582
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
1
2
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { resolveEditingSections } from "@hyperframes/core/editing";
|
|
3
3
|
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
|
4
4
|
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
|
5
|
-
import type { DomEditSelection } from "./domEditing";
|
|
6
5
|
import { isTextEditableSelection } from "./domEditing";
|
|
7
|
-
import type {
|
|
6
|
+
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
|
|
8
7
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
|
9
8
|
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
|
10
9
|
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
|
@@ -34,8 +33,6 @@ import {
|
|
|
34
33
|
FlatOverlaysSection,
|
|
35
34
|
} from "./propertyPanelFlatOverlaysSection";
|
|
36
35
|
|
|
37
|
-
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
|
38
|
-
|
|
39
36
|
type FlatGroupDescriptor = {
|
|
40
37
|
id: string;
|
|
41
38
|
title: string;
|
|
@@ -135,92 +132,9 @@ export function PropertyPanelFlat({
|
|
|
135
132
|
onUpdateArcSegment,
|
|
136
133
|
onUnroll,
|
|
137
134
|
onUpdateKeyframeEase,
|
|
135
|
+
onUpdateSegmentEase,
|
|
138
136
|
onSetAllKeyframeEases,
|
|
139
|
-
}:
|
|
140
|
-
PropertyPanelProps,
|
|
141
|
-
| "projectId"
|
|
142
|
-
| "projectDir"
|
|
143
|
-
| "assets"
|
|
144
|
-
| "previewIframeRef"
|
|
145
|
-
| "onClearSelection"
|
|
146
|
-
| "onUngroup"
|
|
147
|
-
| "onSetStyle"
|
|
148
|
-
| "onPreviewStyle"
|
|
149
|
-
| "onSetAttribute"
|
|
150
|
-
| "onSetAttributes"
|
|
151
|
-
| "onSetAttributeLive"
|
|
152
|
-
| "onApplyColorGradingScope"
|
|
153
|
-
| "onSetHtmlAttribute"
|
|
154
|
-
| "onRemoveBackground"
|
|
155
|
-
| "onSetText"
|
|
156
|
-
| "onSetTextFieldStyle"
|
|
157
|
-
| "onPreviewTextFieldStyle"
|
|
158
|
-
| "onAddTextField"
|
|
159
|
-
| "onRemoveTextField"
|
|
160
|
-
| "onAskAgent"
|
|
161
|
-
| "onToggleElementHidden"
|
|
162
|
-
| "onImportAssets"
|
|
163
|
-
| "onAddMediaOverlay"
|
|
164
|
-
| "onImportFonts"
|
|
165
|
-
| "fontAssets"
|
|
166
|
-
| "gsapAnimations"
|
|
167
|
-
| "gsapMultipleTimelines"
|
|
168
|
-
| "gsapUnsupportedTimelinePattern"
|
|
169
|
-
| "onUpdateGsapProperty"
|
|
170
|
-
| "onUpdateGsapMeta"
|
|
171
|
-
| "onDeleteGsapAnimation"
|
|
172
|
-
| "onAddGsapProperty"
|
|
173
|
-
| "onRemoveGsapProperty"
|
|
174
|
-
| "onUpdateGsapFromProperty"
|
|
175
|
-
| "onAddGsapFromProperty"
|
|
176
|
-
| "onRemoveGsapFromProperty"
|
|
177
|
-
| "onAddGsapAnimation"
|
|
178
|
-
| "onSetArcPath"
|
|
179
|
-
| "onUpdateArcSegment"
|
|
180
|
-
| "onUnroll"
|
|
181
|
-
| "onUpdateKeyframeEase"
|
|
182
|
-
| "onSetAllKeyframeEases"
|
|
183
|
-
| "recordingState"
|
|
184
|
-
| "recordingDuration"
|
|
185
|
-
| "onToggleRecording"
|
|
186
|
-
> &
|
|
187
|
-
Pick<
|
|
188
|
-
Parameters<typeof FlatLayoutSection>[0],
|
|
189
|
-
| "displayX"
|
|
190
|
-
| "displayY"
|
|
191
|
-
| "displayW"
|
|
192
|
-
| "displayH"
|
|
193
|
-
| "displayR"
|
|
194
|
-
| "manualOffsetEditingDisabled"
|
|
195
|
-
| "manualSizeEditingDisabled"
|
|
196
|
-
| "manualRotationEditingDisabled"
|
|
197
|
-
| "commitManualOffset"
|
|
198
|
-
| "commitManualSize"
|
|
199
|
-
| "commitManualRotation"
|
|
200
|
-
| "gsapAnimId"
|
|
201
|
-
| "navKeyframes"
|
|
202
|
-
| "animIdForProp"
|
|
203
|
-
| "gsapRuntimeValues"
|
|
204
|
-
| "elStart"
|
|
205
|
-
| "elDuration"
|
|
206
|
-
| "onCommitAnimatedProperty"
|
|
207
|
-
| "onCommitAnimatedProperties"
|
|
208
|
-
| "onSeekToTime"
|
|
209
|
-
| "onRemoveKeyframe"
|
|
210
|
-
| "onConvertToKeyframes"
|
|
211
|
-
> & {
|
|
212
|
-
element: DomEditSelection;
|
|
213
|
-
styles: Record<string, string>;
|
|
214
|
-
sections: EditingSections;
|
|
215
|
-
sourceLabel: string;
|
|
216
|
-
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
|
|
217
|
-
showEditableSections: boolean;
|
|
218
|
-
selectedElementHidden: boolean;
|
|
219
|
-
selectedElementId: string | null;
|
|
220
|
-
clipboardCopied: boolean;
|
|
221
|
-
onCopyElementInfo: () => void;
|
|
222
|
-
currentTime: number;
|
|
223
|
-
}) {
|
|
137
|
+
}: PropertyPanelFlatProps) {
|
|
224
138
|
// PropertyPanel keys this component by selection, so the default is per element.
|
|
225
139
|
const [openGroupId, setOpenGroupId] = useState<string>(() =>
|
|
226
140
|
isTextEditableSelection(element)
|
|
@@ -253,7 +167,7 @@ export function PropertyPanelFlat({
|
|
|
253
167
|
// flips synchronously while the panel still renders its predecessor, so a
|
|
254
168
|
// stale panel would consume a request meant for its successor whenever the
|
|
255
169
|
// two share a class-selector animation id.
|
|
256
|
-
const renderedElementId =
|
|
170
|
+
const renderedElementId = scopedElementKey(element);
|
|
257
171
|
// Adjusted during render (not an effect) so the card mounts on the same
|
|
258
172
|
// commit the request lands on. Keyed on request identity: a group the user
|
|
259
173
|
// closes afterwards stays closed.
|
|
@@ -330,6 +244,7 @@ export function PropertyPanelFlat({
|
|
|
330
244
|
onUpdateArcSegment,
|
|
331
245
|
onUnroll,
|
|
332
246
|
onUpdateKeyframeEase,
|
|
247
|
+
onUpdateSegmentEase,
|
|
333
248
|
onSetAllKeyframeEases,
|
|
334
249
|
}
|
|
335
250
|
: null;
|
|
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
26
26
|
const onLivePreviewEnd = vi.fn();
|
|
27
27
|
callbacks.onLivePreview = onLivePreview;
|
|
28
28
|
callbacks.onLivePreviewEnd = onLivePreviewEnd;
|
|
29
|
-
|
|
30
29
|
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
|
|
31
30
|
|
|
32
31
|
expect(tracked.onUpdateFromProperty).toBeUndefined();
|
|
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
37
36
|
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
|
|
38
37
|
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
|
|
39
38
|
expect(tracked.onUnroll).toBeUndefined();
|
|
39
|
+
expect(tracked.onUpdateSegmentEase).toBeUndefined();
|
|
40
40
|
expect(tracked.onLivePreview).toBe(onLivePreview);
|
|
41
41
|
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
|
|
42
42
|
});
|
|
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
57
57
|
onUpdateArcSegment: mutation("arc-segment"),
|
|
58
58
|
onUpdateKeyframeEase: mutation("keyframe-ease"),
|
|
59
59
|
onSetAllKeyframeEases: mutation("all-eases"),
|
|
60
|
+
onUpdateSegmentEase: mutation("segment-ease"),
|
|
60
61
|
onUnroll: mutation("unroll"),
|
|
61
62
|
};
|
|
62
63
|
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
|
|
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
79
80
|
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
|
|
80
81
|
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
|
|
81
82
|
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
|
|
83
|
+
requireCallback(tracked.onUpdateSegmentEase)(
|
|
84
|
+
[{ animationId: "a1", tweenPercentage: 50 }],
|
|
85
|
+
"none",
|
|
86
|
+
);
|
|
82
87
|
requireCallback(tracked.onUnroll)("a1");
|
|
83
88
|
|
|
84
89
|
expect(events).toEqual([
|
|
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
115
120
|
"mutate:keyframe-ease",
|
|
116
121
|
"track:select:All keyframe eases",
|
|
117
122
|
"mutate:all-eases",
|
|
123
|
+
"track:select:Segment ease",
|
|
124
|
+
"mutate:segment-ease",
|
|
118
125
|
"track:button:Unroll animation",
|
|
119
126
|
"mutate:unroll",
|
|
120
127
|
]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
|
2
2
|
import { usePlayerStore } from "../../player";
|
|
3
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
|
|
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
|
|
|
30
31
|
update: Partial<ArcPathSegment>,
|
|
31
32
|
) => void;
|
|
32
33
|
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
34
|
+
onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
|
|
33
35
|
/** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
|
|
34
36
|
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
35
37
|
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
|
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
|
|
|
122
124
|
: undefined,
|
|
123
125
|
onLivePreview: callbacks.onLivePreview,
|
|
124
126
|
onLivePreviewEnd: callbacks.onLivePreviewEnd,
|
|
127
|
+
onUpdateSegmentEase: callbacks.onUpdateSegmentEase
|
|
128
|
+
? (targets, ease) => {
|
|
129
|
+
track("select", "Segment ease");
|
|
130
|
+
callbacks.onUpdateSegmentEase?.(targets, ease);
|
|
131
|
+
}
|
|
132
|
+
: undefined,
|
|
125
133
|
onSetArcPath: callbacks.onSetArcPath
|
|
126
134
|
? (animationId, config) => {
|
|
127
135
|
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
|