@@ -279,7 +277,8 @@ export function StudioHeader({
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
- clearDomSelection();
+ // Keep the current selection when collapsing the Inspector — closing
+ // the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx
index cfa981a3e7..b42a06837d 100644
--- a/packages/studio/src/components/StudioPreviewArea.tsx
+++ b/packages/studio/src/components/StudioPreviewArea.tsx
@@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout";
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { DomEditOverlay } from "./editor/DomEditOverlay";
+import { MotionPathOverlay } from "./editor/MotionPathOverlay";
+import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
import { SnapToolbar } from "./editor/SnapToolbar";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import type { TimelineElement } from "../player";
@@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
+ STUDIO_KEYFRAMES_ENABLED,
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
STUDIO_PREVIEW_SELECTION_ENABLED,
} from "./editor/manualEditingAvailability";
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
isPlaying,
refreshPreviewDocumentVersion,
} = useStudioPlaybackContext();
+ const compositionDimensions = useCompositionDimensions();
const {
domEditHoverSelection,
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
onToggleRecording={onToggleRecording}
/>
+ {STUDIO_KEYFRAMES_ENABLED && (
+
+ )}
{gestureOverlay}
>
) : null
diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx
index ecd584a145..fd9d9ac134 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -1,5 +1,10 @@
import { useRef } from "react";
-import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
+import {
+ useEnableKeyframes,
+ isPlayheadWithinTween,
+ type EnableKeyframesSession,
+} from "../hooks/useEnableKeyframes";
+import { computeElementPercentage } from "../hooks/gsapShared";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
@@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
- const computePct = (time: number) => {
- const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
- const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
- return elDuration > 0
- ? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
- : 0;
- };
-
let state: "active" | "inactive" | "none" = "none";
+ // Outside the tween, clicking extends the animation to the playhead rather than
+ // toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
+ let willExtend = false;
if (kfAnim?.keyframes && sel) {
- const pct = computePct(currentTime);
- state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
- ? "active"
- : "inactive";
+ if (!isPlayheadWithinTween(kfAnim, currentTime)) {
+ state = "inactive";
+ willExtend = true;
+ } else {
+ // Tween-relative percentage (not the clip range) so the button state matches
+ // where the keyframe would actually land.
+ const pct = computeElementPercentage(currentTime, sel, kfAnim);
+ state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
+ ? "active"
+ : "inactive";
+ }
}
- return { state, onToggle: sel ? onToggle : undefined };
+ return { state, willExtend, onToggle: sel ? onToggle : undefined };
}
// fallow-ignore-next-line complexity
@@ -76,7 +83,11 @@ export function TimelineToolbar({
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
- const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
+ const {
+ state: keyframeState,
+ willExtend: keyframeWillExtend,
+ onToggle: onToggleKeyframe,
+ } = useKeyframeToggle(domEditSession);
return (
@@ -124,7 +135,9 @@ export function TimelineToolbar({
keyframeState === "active"
? "Remove keyframe at playhead"
: keyframeState === "inactive"
- ? "Add keyframe at playhead"
+ ? keyframeWillExtend
+ ? "Add keyframe at playhead (extends animation)"
+ : "Add keyframe at playhead"
: "Enable keyframes"
}
>
diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts
index 7fb292913c..468bebfdfe 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.test.ts
+++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts
@@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
};
let currentSelection: DomEditSelection | null = selection;
- const onToggleRecording = vi.fn();
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
- recordingState: "idle",
- onToggleRecording,
});
}
@@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
"drag",
expect.objectContaining({ button: 0 }),
);
- const recordButton = host.querySelector(
- '[aria-label="Record gesture (R)"]',
- ) as HTMLButtonElement;
- expect(recordButton).toBeTruthy();
-
- act(() => {
- recordButton.click();
- });
-
- expect(onToggleRecording).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index 12789407c6..5e0f736dd4 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
-import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
+import type { GestureRecordingState } from "./GestureRecordControl";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -55,6 +55,7 @@ interface DomEditOverlayProps {
onPathOffsetCommit: (
selection: DomEditSelection,
next: { x: number; y: number },
+ modifiers?: { altKey?: boolean },
) => Promise | void;
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise | void;
onBoxSizeCommit: (
@@ -87,8 +88,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
- recordingState,
- onToggleRecording,
}: DomEditOverlayProps) {
const overlayRef = useRef(null);
const boxRef = useRef(null);
@@ -243,6 +242,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
if (!selection) return "none";
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
}, [selection]);
+
const groupBounds = useMemo(
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
[groupOverlayItems],
@@ -433,13 +433,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
)}
- {onToggleRecording && (
-
- )}
{
+ it("maps anchor keyframes to their tween-relative percentages", () => {
+ expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
+ expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
+ });
+
+ it("linearly interpolates a clip-relative playhead into tween space", () => {
+ // clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
+ expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
+ });
+
+ it("falls back to the input when there's no usable mapping", () => {
+ expect(clipToTweenPercentage([], 40)).toBe(40);
+ expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
+ });
+});
diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx
index 48f2f51770..c54047c305 100644
--- a/packages/studio/src/components/editor/KeyframeNavigation.tsx
+++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx
@@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
interface KeyframeNavigationProps {
property: string;
- /** All keyframes for this element's tween, or null if no keyframes exist */
+ /** All keyframes for this element's tween, or null if no keyframes exist.
+ * `percentage` is clip-relative (element lifetime) for display/seek;
+ * `tweenPercentage` is the tween-relative value the writer/runtime key on. */
keyframes: Array<{
percentage: number;
+ tweenPercentage?: number;
properties: Record
;
ease?: string;
}> | null;
@@ -19,6 +22,26 @@ interface KeyframeNavigationProps {
const TOLERANCE = 0.5;
+/**
+ * Convert a clip-relative percentage (element lifetime, used for display/seek) to
+ * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
+ * map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
+ * pairs. Falls back to the input when there's no usable mapping (e.g. parser
+ * keyframes that are already tween-relative, or fewer than two anchors).
+ */
+export function clipToTweenPercentage(
+ keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
+ clipPct: number,
+): number {
+ const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
+ if (mapped.length < 2) return clipPct;
+ const a = mapped[0]!;
+ const b = mapped[mapped.length - 1]!;
+ if (b.percentage === a.percentage) return a.tweenPercentage!;
+ const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
+ return a.tweenPercentage! + (clipPct - a.percentage) * slope;
+}
+
function ArrowLeft({ disabled }: { disabled: boolean }) {
return (