mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Selecting an audio bus showed a Motion section offering tween editors. A bus has no transform, opacity or box, so every effect on that list moves nothing; the same is true of an `<audio>` clip. The panel's gate was "are the GSAP handlers wired", and `App.tsx` always wires them, so it was true for every selection. It now also asks what was selected. Audio keeps its timing — an `<audio>` clip is placed on the timeline like anything else — but the section is called Timing there and summarises its span instead of an effect count, because "Motion: 0 effects" on a sound is a category error. A bus loses timing too: it has no `data-start` and no duration, and its automation clock is composition time, so Start / Duration / End would be editing nothing. Gated on the TAG, not on `sections.animation`: a `div` with no tweens yet must still offer "+ Add", and keying the rename on `animationCount > 0` renamed the section for exactly that div — which the existing panel test caught. Keying it on `showMotionEffects` renamed it for an `img`, which wires no GSAP handlers. Both halves belong to the tag. `affordances.ts` carries the same two rules for anything reading the section list rather than the flat panel. The label pair is `motionSectionLabel`, in the module that owns the section it names, which is also what keeps `PropertyPanelFlat.tsx` under the 600-line ceiling.
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
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<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) {
|
||
// 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 (
|
||
<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 && (
|
||
<GsapAnimationList
|
||
{...callbacks}
|
||
elementId={renderedElementId}
|
||
animations={animations}
|
||
onAddAnimation={onAddAnimation}
|
||
variant="flat"
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* What the Motion section is called, and what its collapsed line says.
|
||
*
|
||
* "Motion" names the tween editor. On audio the section is Start/Duration/End
|
||
* and nothing else, so the label would promise what it no longer offers — and
|
||
* "Motion: 0 effects" on a sound is a category error, hence the span instead of
|
||
* a count.
|
||
*
|
||
* Keyed on the TAG by its caller, not on whether the effects half is showing:
|
||
* that half also disappears when a host simply has not wired the GSAP handlers,
|
||
* and a div in that state is still a thing that moves — renaming its section
|
||
* would be describing the host's wiring rather than the element.
|
||
*/
|
||
export function motionSectionLabel(args: {
|
||
timingOnly: boolean;
|
||
start: number;
|
||
duration: number;
|
||
effectCount: number;
|
||
}): { title: string; summary: string } {
|
||
if (args.timingOnly) {
|
||
return {
|
||
title: "Timing",
|
||
summary: `${formatTimingValue(args.start)} – ${formatTimingValue(args.start + args.duration)}`,
|
||
};
|
||
}
|
||
return {
|
||
title: "Motion",
|
||
summary: `${args.effectCount} effect${args.effectCount === 1 ? "" : "s"}`,
|
||
};
|
||
}
|