Skip to content

Commit 5f9725e

Browse files
committed
fix(studio): span the clip on the remaining duration-less tween paths
Two sibling call paths still answered from GSAP's 0.5s default while the toggle path had moved to the clip-wide fallback. isPlayheadWithinTween now takes the selection, so the toolbar stops promising "extends animation" on a duration-less tween whose click actually toggles an interior keyframe. The arc drag commit resolves its replacement duration the same way its click-path sibling does, instead of authoring duration 0.5 and collapsing the arc window. The flat text section drops its two marker-clearing effects: the autofocus marker is a ref now, read and cleared by the render that consumes it.
1 parent 716dea3 commit 5f9725e

6 files changed

Lines changed: 50 additions & 20 deletions

File tree

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function resolveKeyframeToggleState(
7373
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
7474

7575
const isMotionPath = Boolean(arcAnimation);
76-
if (!isPlayheadWithinTween(animation, currentTime)) {
76+
if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
7777
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
7878
}
7979

packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
33
import { Plus, X } from "../../icons/SystemIcons";
44
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
@@ -257,7 +257,10 @@ export function FlatTextSection({
257257
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
258258
element.textFields[0]?.key ?? null,
259259
);
260-
const [autoFocusFieldKey, setAutoFocusFieldKey] = useState<string | null>(null);
260+
// Armed by the add handler, read and cleared by the render that first shows
261+
// the new field. A ref rather than state: the marker only has to survive one
262+
// render, and clearing it afterwards would be a state-syncing effect.
263+
const autoFocusFieldKeyRef = useRef<string | null>(null);
261264

262265
useEffect(() => {
263266
const nextFields = element.textFields;
@@ -267,21 +270,14 @@ export function FlatTextSection({
267270
});
268271
}, [element.id, element.selector, element.textFields]);
269272

270-
useEffect(() => {
271-
setAutoFocusFieldKey(null);
272-
}, [element.id, element.selector]);
273-
274-
useEffect(() => {
275-
if (autoFocusFieldKey && autoFocusFieldKey === activeFieldKey) {
276-
setAutoFocusFieldKey(null);
277-
}
278-
}, [activeFieldKey, autoFocusFieldKey]);
279-
280273
if (!isTextEditableSelection(element)) return null;
281274
const textFields = element.textFields;
282275
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
283276
if (!activeField) return null;
284277

278+
const autoFocusActiveField = autoFocusFieldKeyRef.current === activeField.key;
279+
if (autoFocusActiveField) autoFocusFieldKeyRef.current = null;
280+
285281
if (textFields.length > 1) {
286282
return (
287283
<div className="space-y-2.5">
@@ -290,13 +286,13 @@ export function FlatTextSection({
290286
activeFieldKey={activeField.key}
291287
styles={styles}
292288
onSelect={(fieldKey) => {
293-
setAutoFocusFieldKey(null);
289+
autoFocusFieldKeyRef.current = null;
294290
setActiveFieldKey(fieldKey);
295291
}}
296292
onAdd={() =>
297293
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
298294
if (!nextKey) return;
299-
setAutoFocusFieldKey(nextKey);
295+
autoFocusFieldKeyRef.current = nextKey;
300296
setActiveFieldKey(nextKey);
301297
})
302298
}
@@ -311,7 +307,7 @@ export function FlatTextSection({
311307
onSetText={onSetText}
312308
onSetTextFieldStyle={onSetTextFieldStyle}
313309
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
314-
autoFocus={autoFocusFieldKey === activeField.key}
310+
autoFocus={autoFocusActiveField}
315311
/>
316312
</div>
317313
);
@@ -334,7 +330,7 @@ export function FlatTextSection({
334330
track("button", "Add text field");
335331
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
336332
if (!nextKey) return;
337-
setAutoFocusFieldKey(nextKey);
333+
autoFocusFieldKeyRef.current = nextKey;
338334
setActiveFieldKey(nextKey);
339335
});
340336
}}

packages/studio/src/hooks/gsapDragPositionCommit.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { usePlayerStore } from "../player/store/playerStore";
44
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
55
import { roundTo3 } from "../utils/rounding";
66
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
7+
import { resolveEditableTweenDuration } from "./gsapShared";
78
import {
89
type GsapDragCommitCallbacks,
910
computeCurrentPercentage,
@@ -297,7 +298,9 @@ export async function commitGsapPositionFromDrag(
297298
}
298299

299300
const tweenStart = resolveTweenStart(anim);
300-
const tweenDuration = resolveTweenDuration(anim);
301+
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
302+
// default here would collapse a duration-less arc's window on drag.
303+
const tweenDuration = resolveEditableTweenDuration(anim, selection);
301304
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
302305
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
303306
await callbacks.commitMutation(

packages/studio/src/hooks/useEnableKeyframes.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ describe("isPlayheadWithinTween", () => {
108108
it("does not block when the start can't be resolved", () => {
109109
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
110110
});
111+
112+
// The toolbar's "extends animation" tooltip has to agree with what the edit
113+
// paths do. Those span a duration-less tween across its clip, so answering
114+
// from GSAP's 0.5s default reported the playhead outside a window the click
115+
// then treated as clip-wide.
116+
it("spans the clip for a duration-less tween when given the selection", () => {
117+
const durationless = anim({ position: 0 });
118+
const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection;
119+
120+
expect(isPlayheadWithinTween(durationless, 5)).toBe(false);
121+
expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true);
122+
expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false);
123+
});
111124
});
112125

113126
describe("buildExtendedKeyframes", () => {

packages/studio/src/hooks/useEnableKeyframes.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,22 @@ export function animatedProps(anim: GsapAnimation | null): string[] {
7777
* Whether the playhead sits inside an animation's tween range. When the tween's
7878
* start can't be resolved we don't block (the percentage falls back to clip range,
7979
* preserving prior behavior for elements without explicit timing).
80+
*
81+
* Pass the selection whenever the caller has one: a duration-less tween spans
82+
* its clip, and answering from GSAP's 0.5s default reports the playhead outside
83+
* a window the edit paths treat as clip-wide.
8084
*/
81-
export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
85+
export function isPlayheadWithinTween(
86+
anim: GsapAnimation,
87+
currentTime: number,
88+
selection?: DomEditSelection | null,
89+
): boolean {
8290
const start = resolveTweenStart(anim);
8391
if (start === null) return true;
84-
return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
92+
const duration = selection
93+
? resolveEditableTweenDuration(anim, selection)
94+
: resolveTweenDuration(anim);
95+
return isTimeWithinTween(currentTime, start, duration);
8596
}
8697

8798
/**

packages/studio/src/player/components/timelineKeyframeIdentity.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ export interface TimelineKeyframeTarget {
55
animationId?: string;
66
}
77

8+
/**
9+
* Note the asymmetry: `tweenPercentage` is optional here and defaults to
10+
* `percentage`, but the reader treats both slots as authoritative. Callers must
11+
* therefore keep the pair consistent — writing a new `tweenPercentage` without
12+
* the matching `percentage` hashes two logically-equal selections to different
13+
* keys.
14+
*/
815
export function timelineKeyframeSelectionKey(
916
elementId: string,
1017
target: TimelineKeyframeTarget,

0 commit comments

Comments
 (0)