import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { parseTimingValue } from "./propertyPanelTimingSection"; import { CommitField } from "./propertyPanelPrimitives"; import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { GsapAnimationList } from "./GsapAnimationList"; export function FlatTimingRow({ element, animations = [], onSetAttribute, onSetAttributes, }: { element: DomEditSelection; animations?: GsapAnimation[]; onSetAttribute: (attr: string, value: string) => void | Promise; /** Commits start+duration together in ONE atomic persist call, bound to * THIS render's `element` explicitly — not whatever is "currently" * selected by the time the call resolves. Falls back to two sequential * `onSetAttribute` calls (with the same non-atomicity/misdirection risk * documented below) when the caller doesn't wire it up. */ onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; }) { const track = useTrackDesignInput(); const { start, duration, inferred: derived } = deriveElementTiming(element, animations); const end = start + duration; // While the range is inferred from animations, editing ONE field must pin the // WHOLE displayed range: writing only data-duration flips inference off and // drops start to data-start-or-0 (the clip silently shifts), and writing only // data-start is ignored while duration is still inferred (the edit looks // dead). Pin both attributes in ONE atomic commit bound to THIS element — // two sequential `onSetAttribute` calls would each resolve `domEditSelection` // fresh from current hook state, so a selection change between the two // awaits could misdirect the second write at the newly-selected element, and // a failure of just the second call would leave the pair half-applied. const pinRange = async (nextStart: number, nextDuration: number) => { const attrs = { start: nextStart.toFixed(2), duration: nextDuration.toFixed(2) }; if (onSetAttributes) { await onSetAttributes(element, attrs); return; } await onSetAttribute("start", attrs.start); await onSetAttribute("duration", attrs.duration); }; const commitStart = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null) return; if (derived) { void pinRange(parsed, duration); return; } void onSetAttribute("start", parsed.toFixed(2)); }; const commitDuration = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null || parsed <= 0) return; if (derived) { void pinRange(start, parsed); return; } void onSetAttribute("duration", parsed.toFixed(2)); }; const commitEnd = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null || parsed <= start) return; if (derived) { void pinRange(start, parsed - start); return; } void onSetAttribute("duration", (parsed - start).toFixed(2)); }; const cell = (label: string, value: string, onCommit: (next: string) => void) => (
{label} { track("metric", label); onCommit(next); }} />
); return (
{cell("Start", formatTimingValue(start), commitStart)} {cell("End", formatTimingValue(end), commitEnd)} {cell("Duration", formatTimingValue(duration), commitDuration)} {derived && (

Inferred from this element's animation — edit to pin an explicit clip range.

)}
); } export function FlatMotionSection({ element, animations, showTiming, showEffects, multipleTimelines, unsupportedTimelinePattern, onSetAttribute, onSetAttributes, onAddAnimation, ...callbacks }: { element: DomEditSelection; animations: GsapAnimation[]; showTiming: boolean; showEffects: boolean; multipleTimelines?: boolean; unsupportedTimelinePattern?: boolean; onSetAttribute: (attr: string, value: string) => void | Promise; onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; } & GsapAnimationEditCallbacks) { // Only consume a focus request aimed at the element THIS panel renders (not // the store's selectedElementId, which flips synchronously during async // selection resolution), so a shared class-selector animation id can't open // the wrong element's editor. const renderedElementId = scopedElementKey(element); return (
{showTiming && ( )} {showEffects && ( <> {multipleTimelines && (

This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss — consolidate into a single timeline to enable editing.

)} {unsupportedTimelinePattern && (

This timeline uses a computed key the editor can't resolve statically.

)} {!multipleTimelines && !unsupportedTimelinePattern && ( )} )}
); }