refactor(studio): split the diamond row's data contract into its own file

TimelineClipDiamonds.tsx had grown past the 600-line studio file gate, and
its keyframe/props types were declared a second time next to the ones the
lanes already share. The types, the render constants, and the keyframe
identity helper move to timelineDiamondTypes.ts; the rendering stays put.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:13:47 +02:00
parent cbdd61d102
commit f5d3c7a3d1
2 changed files with 130 additions and 115 deletions
@@ -8,123 +8,18 @@ import {
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection"; import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
import { LANE_H } from "./timelineLayout"; import { LANE_H } from "./timelineLayout";
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
import { import {
timelineKeyframeSelectionKey, DIAMOND_RATIO,
type TimelineKeyframeTarget, KF_MAX_PCT,
} from "./timelineKeyframeIdentity"; KF_MIN_PCT,
export interface TimelineDiamondKeyframe { keyframeTarget,
percentage: number; type DragState,
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */ type TimelineClipDiamondsProps,
tweenPercentage?: number; type TimelineDiamondLaneProps,
propertyGroup?: string; } from "./timelineDiamondTypes";
animationId?: string;
properties: Record<string, number | string>;
ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline
* ease button can't target one): the collapsed row hides the button here. */
easeAmbiguous?: boolean;
}
interface KeyframeCacheEntry { export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
format: string;
keyframes: TimelineDiamondKeyframe[];
ease?: string;
easeEach?: string;
}
interface TimelineClipDiamondsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipHeightPx: number;
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
* the bottom half so they clear the strip at the top. */
beatsActive?: boolean;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: ReadonlySet<string>;
onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (
e: React.MouseEvent,
elementId: string,
keyframe: TimelineKeyframeTarget,
) => void;
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
* `keyframe` identifies the dragged keyframe (clip-relative percentage plus
* whatever animation identity the row carries); `toClipPercentage` is the
* neighbour-clamped drop position, also clip-relative. The handler decides
* move (within the tween) vs resize (past its boundary). */
onMoveKeyframe?: (
elementId: string,
keyframe: TimelineKeyframeTarget,
toClipPercentage: number,
) => Promise<boolean>;
/** Open the segment ease editor for the hovered mid-point button — available on
* the inline clip row too, not just the expanded lanes. */
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
/** Set while resolving a diamond press so the ancestor clip's onClick (which
* toggles selection off when already selected) ignores the native "click"
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
suppressClickRef?: React.RefObject<boolean>;
}
interface TimelineDiamondLaneProps extends Omit<
TimelineClipDiamondsProps,
| "onClickKeyframe"
| "onShiftClickKeyframe"
| "onContextMenuKeyframe"
| "onMoveKeyframe"
| "onSelectSegment"
> {
groupAware?: boolean;
globalEase?: string;
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
}
const DIAMOND_RATIO = 0.8;
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
// slightly outside [0, 100] (from rounding or stale cache during the async
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
// their true position rather than hidden.
const KF_MIN_PCT = -5;
const KF_MAX_PCT = 105;
type DragState = {
kfKey: string;
startX: number;
fromClipPct: number;
moved: boolean;
/** Latest pointer x, flushed to the preview once per frame. */
lastX: number;
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
index: number;
/** Escape was pressed: the drag is dead, and the pointerup that follows is
* swallowed rather than falling through to the click branch. */
cancelled?: boolean;
};
/**
* The full identity of a diamond, used by every callback and by the selection
* key. Collapsed clip rows and expanded property lanes read the same cache, so
* they must hash a shared keyframe to the same key: dropping the group here for
* the collapsed row would leave a diamond selected in one view and unselected in
* the other, and would strip the animation id the retime/delete mutations use to
* pick between two animations that collide at one percentage.
*/
function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
return {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
propertyGroup: keyframe.propertyGroup,
animationId: keyframe.animationId,
};
}
export const TimelineDiamondLane = memo(function TimelineDiamondLane({ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
keyframesData, keyframesData,
@@ -0,0 +1,120 @@
/**
* The diamond row's data + prop contract, split out of TimelineClipDiamonds so
* that file stays under the 600-line studio gate. Types and the shared
* keyframe-identity helper live here; the rendering lives there.
*/
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
export interface TimelineDiamondKeyframe {
percentage: number;
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
properties: Record<string, number | string>;
ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline
* ease button can't target one): the collapsed row hides the button here. */
easeAmbiguous?: boolean;
}
interface KeyframeCacheEntry {
format: string;
keyframes: TimelineDiamondKeyframe[];
ease?: string;
easeEach?: string;
}
export interface TimelineClipDiamondsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipHeightPx: number;
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
* the bottom half so they clear the strip at the top. */
beatsActive?: boolean;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: ReadonlySet<string>;
onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (
e: React.MouseEvent,
elementId: string,
keyframe: TimelineKeyframeTarget,
) => void;
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
* `keyframe` identifies the dragged keyframe (clip-relative percentage plus
* whatever animation identity the row carries); `toClipPercentage` is the
* neighbour-clamped drop position, also clip-relative. The handler decides
* move (within the tween) vs resize (past its boundary). */
onMoveKeyframe?: (
elementId: string,
keyframe: TimelineKeyframeTarget,
toClipPercentage: number,
) => Promise<boolean>;
/** Open the segment ease editor for the hovered mid-point button — available on
* the inline clip row too, not just the expanded lanes. */
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
/** Set while resolving a diamond press so the ancestor clip's onClick (which
* toggles selection off when already selected) ignores the native "click"
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
suppressClickRef?: React.RefObject<boolean>;
}
export interface TimelineDiamondLaneProps extends Omit<
TimelineClipDiamondsProps,
| "onClickKeyframe"
| "onShiftClickKeyframe"
| "onContextMenuKeyframe"
| "onMoveKeyframe"
| "onSelectSegment"
> {
groupAware?: boolean;
globalEase?: string;
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
}
export const DIAMOND_RATIO = 0.8;
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
// slightly outside [0, 100] (from rounding or stale cache during the async
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
// their true position rather than hidden.
export const KF_MIN_PCT = -5;
export const KF_MAX_PCT = 105;
export type DragState = {
kfKey: string;
startX: number;
fromClipPct: number;
moved: boolean;
/** Latest pointer x, flushed to the preview once per frame. */
lastX: number;
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
index: number;
/** Escape was pressed: the drag is dead, and the pointerup that follows is
* swallowed rather than falling through to the click branch. */
cancelled?: boolean;
};
/**
* The full identity of a diamond, used by every callback and by the selection
* key. Collapsed clip rows and expanded property lanes read the same cache, so
* they must hash a shared keyframe to the same key: dropping the group here for
* the collapsed row would leave a diamond selected in one view and unselected in
* the other, and would strip the animation id the retime/delete mutations use to
* pick between two animations that collide at one percentage.
*/
export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
return {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
propertyGroup: keyframe.propertyGroup,
animationId: keyframe.animationId,
};
}