Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx
T
Vance Ingalls 553ea25931 fix(studio,core): audio has timing, not motion — and a bus has neither
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.
2026-08-23 02:06:58 -07:00

206 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&apos;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"}`,
};
}