import { memo, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; import { KEYFRAME_DRAG_THRESHOLD_PX, previewClipPct, resolveKeyframeDrag, } from "../../components/editor/keyframeDrag"; interface KeyframeEntry { percentage: number; /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ tweenPercentage?: number; properties: Record; ease?: string; } interface KeyframeCacheEntry { format: string; keyframes: KeyframeEntry[]; 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: Set; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease. * Both percentages are clip-relative: `fromClipPercentage` identifies the * dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position. * The handler decides move (within the tween) vs resize (past its boundary). */ onMoveKeyframe?: ( elementId: string, fromClipPercentage: number, toClipPercentage: number, ) => 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; } 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; }; export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ keyframesData, clipWidthPx, clipHeightPx, beatsActive, accentColor, isSelected, currentPercentage, elementId, selectedKeyframes, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, suppressClickRef, }: TimelineClipDiamondsProps) { // Hooks must run before the early return below. const dragRef = useRef(null); // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit // on drop re-keys the diamond from source. const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null); // The button element can re-render (reposition/unmount) synchronously from // the state updates onClickKeyframe/onMoveKeyframe trigger, before the // browser gets to auto-synthesize the "click" event that normally follows // pointerdown+pointerup on a button. That orphaned click then fires on // whatever ancestor is still there — the clip wrapper — whose own onClick // toggles selection off when the clip is already selected (the state a // diamond click always happens in). Suppressing it here is the same fix // already used for clip drag/resize in useTimelineClipDrag.ts. const suppressNextClick = () => { if (!suppressClickRef) return; suppressClickRef.current = true; requestAnimationFrame(() => { suppressClickRef.current = false; }); }; if (clipWidthPx < 20) return null; // When the beat strip occupies the top band, shrink the diamonds and center // them in the remaining bottom region so they don't collide with it. const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); const half = diamondSize / 2; const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; const sorted = keyframesData.keyframes .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT) .sort((a, b) => a.percentage - b.percentage); // Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs // the whole row to bound the dragged diamond between its immediate siblings. const sortedClipPcts = sorted.map((k) => k.percentage); const baseColor = isSelected ? accentColor : "#a3a3a3"; const baseOpacity = isSelected ? 0.4 : 0.25; const canDrag = isSelected && !!onMoveKeyframe; return (
{sorted.map((kf, i) => { if (i === 0) return null; const prev = sorted[i - 1]!; const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); if (x2 - x1 < 1) return null; return (
); })} {sorted.map((kf, i) => { const kfKey = `${elementId}:${kf.percentage}`; // While dragging this diamond, render it at the live preview clip-%. const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; // Center the diamond ON its keyframe %: left = (% · width) − half so the // diamond's midpoint sits exactly at the percentage. At 0% the midpoint // is the clip's left edge (the left half overflows, which the // overflow-visible clip shows) — NOT shifted fully inside. const leftPx = (renderPct / 100) * clipWidthPx - half; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const isHighlighted = isKfSelected || atPlayhead; const color = isHighlighted ? accentColor : "#a3a3a3"; const onPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; e.stopPropagation(); if (canDrag) { e.currentTarget.setPointerCapture?.(e.pointerId); dragRef.current = { kfKey, startX: e.clientX, fromClipPct: kf.percentage, moved: false, }; } }; const onPointerMove = (e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.kfKey !== kfKey) return; if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) { d.moved = true; } if (d.moved) { setPreview({ kfKey, clipPct: previewClipPct({ pointerDownX: d.startX, pointerMoveX: e.clientX, clipWidthPx, draggedClipPct: d.fromClipPct, draggedIndex: i, sortedClipPcts, }), }); } }; const onPointerUp = (e: React.PointerEvent) => { const d = dragRef.current; // No drag armed (canDrag false / non-primary press) → treat as a click. if (!d || d.kfKey !== kfKey) { if (e.button !== 0) return; suppressNextClick(); if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); else onClickKeyframe?.(kf.percentage); return; } e.stopPropagation(); dragRef.current = null; setPreview(null); e.currentTarget.releasePointerCapture?.(e.pointerId); suppressNextClick(); const res = resolveKeyframeDrag({ pointerDownX: d.startX, pointerUpX: e.clientX, clipWidthPx, draggedClipPct: d.fromClipPct, draggedIndex: i, sortedClipPcts, }); if (res.kind === "click" || res.kind === "noop") { // "noop" is a press with enough pointer jitter to arm a drag (canDrag // is on for every diamond once the clip is selected) that resolved // back onto ~the same position — no real retime, so treat it as the // click it was. Otherwise a normal click with a few px of mouse/ // trackpad drift silently does nothing: no selection, no move. if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); else onClickKeyframe?.(kf.percentage); } else if (res.kind === "move" && res.toClipPct != null) { onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct); // A retime still targeted this exact diamond — park/select it at its // new position, same as a plain click, or a drag that actually moved // something looks identical to one that silently did nothing. onClickKeyframe?.(res.toClipPct); } }; return ( ); })}
); });