@hyperframes/studio 0.7.78 → 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-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.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 +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -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/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- 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/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- 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/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- 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/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.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>
|
|
@@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|
|
167
167
|
};
|
|
168
168
|
|
|
169
169
|
return (
|
|
170
|
+
// The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
|
|
171
|
+
// so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
|
|
172
|
+
// own spacing/inline exception: centred 24px targets here would overlap each
|
|
173
|
+
// other AND the diamond, and one control swallowing its neighbour's clicks is
|
|
174
|
+
// a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
|
|
170
175
|
<div className="flex h-5 items-center gap-0.5">
|
|
171
176
|
<button
|
|
172
177
|
type="button"
|
|
@@ -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
|
);
|
|
@@ -132,7 +138,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
132
138
|
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
|
|
133
139
|
const createSelector = createMode ? selectorFor(selection) : null;
|
|
134
140
|
const compW = compositionSize?.width ?? null;
|
|
135
|
-
|
|
141
|
+
// No one-element selector means the path could only be authored onto the
|
|
142
|
+
// element's class siblings, so the toolbar toggle stays hidden instead of
|
|
143
|
+
// arming a press that the effect below would silently drop.
|
|
144
|
+
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
|
|
136
145
|
|
|
137
146
|
// Publish whether the selected element can take a path so the preview toolbar
|
|
138
147
|
// shows its "set destination" toggle. Drops to false when this overlay unmounts
|
|
@@ -432,21 +441,47 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
432
441
|
};
|
|
433
442
|
|
|
434
443
|
const elementId = selection?.id ?? null;
|
|
435
|
-
// Right-click
|
|
436
|
-
//
|
|
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.
|
|
437
448
|
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
|
|
438
|
-
if (
|
|
449
|
+
if (!animId || !elementId || !timelineElement) return;
|
|
439
450
|
e.preventDefault();
|
|
440
451
|
e.stopPropagation();
|
|
441
452
|
setKfMenu({
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
+
},
|
|
448
466
|
});
|
|
449
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;
|
|
450
485
|
|
|
451
486
|
return (
|
|
452
487
|
<>
|
|
@@ -528,14 +563,17 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
528
563
|
</svg>
|
|
529
564
|
{kfMenu && (
|
|
530
565
|
<KeyframeDiamondContextMenu
|
|
531
|
-
state={kfMenu}
|
|
566
|
+
state={kfMenu.state}
|
|
532
567
|
onClose={() => setKfMenu(null)}
|
|
533
|
-
onDelete={
|
|
534
|
-
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
|
535
|
-
}
|
|
568
|
+
onDelete={onMenuDelete}
|
|
536
569
|
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
539
577
|
}
|
|
540
578
|
/>
|
|
541
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";
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
} from "./propertyPanelHelpers";
|
|
17
18
|
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
18
19
|
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
|
19
|
-
import {
|
|
20
|
+
import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes";
|
|
20
21
|
import { resolveEditingSections } from "@hyperframes/core/editing";
|
|
21
22
|
import { MediaSection } from "./propertyPanelMediaSection";
|
|
22
23
|
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
|
@@ -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,
|
|
@@ -241,12 +243,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
241
243
|
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
|
|
242
244
|
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
|
|
243
245
|
|
|
244
|
-
const animIdForProp = (prop: string): string =>
|
|
245
|
-
|
|
246
|
-
const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group);
|
|
247
|
-
if (groupAnim) return groupAnim.id;
|
|
248
|
-
return gsapAnimId ?? "";
|
|
249
|
-
};
|
|
246
|
+
const animIdForProp = (prop: string): string =>
|
|
247
|
+
resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId);
|
|
250
248
|
|
|
251
249
|
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
|
252
250
|
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
|
@@ -560,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
560
558
|
onAddGsapProperty &&
|
|
561
559
|
onAddGsapAnimation && (
|
|
562
560
|
<GsapAnimationSection
|
|
561
|
+
elementId={scopedElementKey(element)}
|
|
563
562
|
animations={gsapAnimations}
|
|
564
563
|
multipleTimelines={gsapMultipleTimelines}
|
|
565
564
|
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
|
@@ -577,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
577
576
|
onUnroll={onUnroll}
|
|
578
577
|
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
579
578
|
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
|
579
|
+
onUpdateSegmentEase={onUpdateSegmentEase}
|
|
580
580
|
/>
|
|
581
581
|
)}
|
|
582
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;
|