diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 43416414d..3b42013b8 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -73,7 +73,7 @@ function resolveKeyframeToggleState( if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE; const isMotionPath = Boolean(arcAnimation); - if (!isPlayheadWithinTween(animation, currentTime)) { + if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) { return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true }; } diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 636955de6..a85670abe 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; @@ -257,7 +257,10 @@ export function FlatTextSection({ const [activeFieldKey, setActiveFieldKey] = useState( element.textFields[0]?.key ?? null, ); - const [autoFocusFieldKey, setAutoFocusFieldKey] = useState(null); + // Armed by the add handler, read and cleared by the render that first shows + // the new field. A ref rather than state: the marker only has to survive one + // render, and clearing it afterwards would be a state-syncing effect. + const autoFocusFieldKeyRef = useRef(null); useEffect(() => { const nextFields = element.textFields; @@ -267,21 +270,14 @@ export function FlatTextSection({ }); }, [element.id, element.selector, element.textFields]); - useEffect(() => { - setAutoFocusFieldKey(null); - }, [element.id, element.selector]); - - useEffect(() => { - if (autoFocusFieldKey && autoFocusFieldKey === activeFieldKey) { - setAutoFocusFieldKey(null); - } - }, [activeFieldKey, autoFocusFieldKey]); - if (!isTextEditableSelection(element)) return null; const textFields = element.textFields; const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0]; if (!activeField) return null; + const autoFocusActiveField = autoFocusFieldKeyRef.current === activeField.key; + if (autoFocusActiveField) autoFocusFieldKeyRef.current = null; + if (textFields.length > 1) { return (
@@ -290,13 +286,13 @@ export function FlatTextSection({ activeFieldKey={activeField.key} styles={styles} onSelect={(fieldKey) => { - setAutoFocusFieldKey(null); + autoFocusFieldKeyRef.current = null; setActiveFieldKey(fieldKey); }} onAdd={() => void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { if (!nextKey) return; - setAutoFocusFieldKey(nextKey); + autoFocusFieldKeyRef.current = nextKey; setActiveFieldKey(nextKey); }) } @@ -311,7 +307,7 @@ export function FlatTextSection({ onSetText={onSetText} onSetTextFieldStyle={onSetTextFieldStyle} onPreviewTextFieldStyle={onPreviewTextFieldStyle} - autoFocus={autoFocusFieldKey === activeField.key} + autoFocus={autoFocusActiveField} />
); @@ -334,7 +330,7 @@ export function FlatTextSection({ track("button", "Add text field"); void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { if (!nextKey) return; - setAutoFocusFieldKey(nextKey); + autoFocusFieldKeyRef.current = nextKey; setActiveFieldKey(nextKey); }); }} diff --git a/packages/studio/src/hooks/gsapDragPositionCommit.ts b/packages/studio/src/hooks/gsapDragPositionCommit.ts index b54847dca..c5a8c026b 100644 --- a/packages/studio/src/hooks/gsapDragPositionCommit.ts +++ b/packages/studio/src/hooks/gsapDragPositionCommit.ts @@ -4,6 +4,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; +import { resolveEditableTweenDuration } from "./gsapShared"; import { type GsapDragCommitCallbacks, computeCurrentPercentage, @@ -297,7 +298,9 @@ export async function commitGsapPositionFromDrag( } const tweenStart = resolveTweenStart(anim); - const tweenDuration = resolveTweenDuration(anim); + // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s + // default here would collapse a duration-less arc's window on drag. + const tweenDuration = resolveEditableTweenDuration(anim, selection); if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return; const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY }); await callbacks.commitMutation( diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts index 6fc5a823c..f9cac5e9e 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.test.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -108,6 +108,19 @@ describe("isPlayheadWithinTween", () => { it("does not block when the start can't be resolved", () => { expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true); }); + + // The toolbar's "extends animation" tooltip has to agree with what the edit + // paths do. Those span a duration-less tween across its clip, so answering + // from GSAP's 0.5s default reported the playhead outside a window the click + // then treated as clip-wide. + it("spans the clip for a duration-less tween when given the selection", () => { + const durationless = anim({ position: 0 }); + const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection; + + expect(isPlayheadWithinTween(durationless, 5)).toBe(false); + expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true); + expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false); + }); }); describe("buildExtendedKeyframes", () => { diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index f55a8e49d..16299b2bd 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -77,11 +77,22 @@ export function animatedProps(anim: GsapAnimation | null): string[] { * Whether the playhead sits inside an animation's tween range. When the tween's * start can't be resolved we don't block (the percentage falls back to clip range, * preserving prior behavior for elements without explicit timing). + * + * Pass the selection whenever the caller has one: a duration-less tween spans + * its clip, and answering from GSAP's 0.5s default reports the playhead outside + * a window the edit paths treat as clip-wide. */ -export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean { +export function isPlayheadWithinTween( + anim: GsapAnimation, + currentTime: number, + selection?: DomEditSelection | null, +): boolean { const start = resolveTweenStart(anim); if (start === null) return true; - return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim)); + const duration = selection + ? resolveEditableTweenDuration(anim, selection) + : resolveTweenDuration(anim); + return isTimeWithinTween(currentTime, start, duration); } /** diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.ts index c9b761784..e016fe39b 100644 --- a/packages/studio/src/player/components/timelineKeyframeIdentity.ts +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.ts @@ -5,6 +5,13 @@ export interface TimelineKeyframeTarget { animationId?: string; } +/** + * Note the asymmetry: `tweenPercentage` is optional here and defaults to + * `percentage`, but the reader treats both slots as authoritative. Callers must + * therefore keep the pair consistent — writing a new `tweenPercentage` without + * the matching `percentage` hashes two logically-equal selections to different + * keys. + */ export function timelineKeyframeSelectionKey( elementId: string, target: TimelineKeyframeTarget,