mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Three review follow-ups on the editor-callback consolidation. The keyframe-target resolve now takes the clicked element's key and reads that element's keyframe cache. The diamond context menu and move-to-playhead pass no explicit target, so they fell through to the cache of whatever element happened to be selected: opening the menu on a non-selected element's diamond resolved against the wrong keyframes. PropertyPanelFlat opens the Motion group by adjusting state during render instead of in an effect, so the AnimationCard mounts on the same commit the focus request arrives on rather than a frame later. Both animation sections pass a module-level focus consumer instead of a fresh inline arrow, so AnimationCard's focus effect stops re-running on every parent render.
202 lines
7.6 KiB
TypeScript
202 lines
7.6 KiB
TypeScript
import { useState } from "react";
|
|
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 { AnimationCard } from "./AnimationCard";
|
|
import {
|
|
type GsapAnimationEditCallbacks,
|
|
withTrackedGsapAnimationCallbacks,
|
|
clearFocusedEaseSegment,
|
|
} from "./gsapAnimationCallbacks";
|
|
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
import { usePlayerStore } from "../../player";
|
|
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
|
|
|
export function FlatTimingRow({
|
|
element,
|
|
animations = [],
|
|
onSetAttribute,
|
|
onSetAttributes,
|
|
}: {
|
|
element: DomEditSelection;
|
|
animations?: GsapAnimation[];
|
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
/** 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<string, string>) => Promise<void>;
|
|
}) {
|
|
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) => (
|
|
<div className="grid gap-px">
|
|
<span className="text-[9px] text-panel-text-4">{label}</span>
|
|
<span className="border-b border-panel-border-input/50 font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
|
|
<CommitField
|
|
value={value}
|
|
onCommit={(next) => {
|
|
track("metric", label);
|
|
onCommit(next);
|
|
}}
|
|
/>
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className={RESPONSIVE_GRID}>
|
|
{cell("Start", formatTimingValue(start), commitStart)}
|
|
{cell("End", formatTimingValue(end), commitEnd)}
|
|
{cell("Duration", formatTimingValue(duration), commitDuration)}
|
|
{derived && (
|
|
<p className="col-span-3 mt-1 text-[10px] leading-snug text-panel-text-3">
|
|
Inferred from this element's animation — edit to pin an explicit clip range.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<void>;
|
|
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
|
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
} & GsapAnimationEditCallbacks) {
|
|
const track = useTrackDesignInput();
|
|
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
|
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
// 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 = `${element.sourceFile}#${element.id}`;
|
|
const focusedHere =
|
|
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
|
? focusedEaseSegment
|
|
: null;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{showTiming && (
|
|
<FlatTimingRow
|
|
element={element}
|
|
animations={animations}
|
|
onSetAttribute={onSetAttribute}
|
|
onSetAttributes={onSetAttributes}
|
|
/>
|
|
)}
|
|
{showEffects && (
|
|
<>
|
|
{multipleTimelines && (
|
|
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
|
|
This file has multiple GSAP timelines. Animation editing is disabled to prevent data
|
|
loss — consolidate into a single timeline to enable editing.
|
|
</p>
|
|
)}
|
|
{unsupportedTimelinePattern && (
|
|
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
|
|
This timeline uses a computed key the editor can't resolve statically.
|
|
</p>
|
|
)}
|
|
{!multipleTimelines && !unsupportedTimelinePattern && (
|
|
<div className="space-y-2">
|
|
{animations.map((anim, index) => (
|
|
<AnimationCard
|
|
{...trackedCallbacks}
|
|
key={anim.id}
|
|
animation={anim}
|
|
defaultExpanded={index === 0}
|
|
flat
|
|
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
|
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
|
/>
|
|
))}
|
|
<GsapAddAnimationControl
|
|
open={addMenuOpen}
|
|
setOpen={setAddMenuOpen}
|
|
onAddAnimation={onAddAnimation}
|
|
track={track}
|
|
variant="flat"
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|