Extract shared actionBtn and iconBtn constants into lib/utils/styles.ts and replace all 11 local definitions across tool components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
'use client';
|
|
|
|
import { useRef } from 'react';
|
|
import { Plus, Trash2 } from 'lucide-react';
|
|
import { cn, iconBtn } from '@/lib/utils';
|
|
import type { Keyframe } from '@/types/animate';
|
|
|
|
interface Props {
|
|
keyframes: Keyframe[];
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
onAdd: (offset: number) => void;
|
|
onDelete: (id: string) => void;
|
|
onMove: (id: string, newOffset: number) => void;
|
|
embedded?: boolean; // when true, no glass card wrapper (use inside another card)
|
|
}
|
|
|
|
const TICKS = [25, 50, 75];
|
|
|
|
const timelineBtn = cn(iconBtn, 'w-6 h-6');
|
|
|
|
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
|
|
const getOffsetFromEvent = (clientX: number): number => {
|
|
if (!trackRef.current) return 0;
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
const pct = ((clientX - rect.left) / rect.width) * 100;
|
|
return Math.round(Math.min(100, Math.max(0, pct)));
|
|
};
|
|
|
|
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
|
|
onAdd(getOffsetFromEvent(e.clientX));
|
|
};
|
|
|
|
const handlePointerDown = (e: React.PointerEvent, id: string) => {
|
|
e.preventDefault();
|
|
onSelect(id);
|
|
const el = e.currentTarget as HTMLElement;
|
|
el.setPointerCapture(e.pointerId);
|
|
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
|
|
const handleUp = () => {
|
|
el.removeEventListener('pointermove', handleMove);
|
|
el.removeEventListener('pointerup', handleUp);
|
|
};
|
|
el.addEventListener('pointermove', handleMove);
|
|
el.addEventListener('pointerup', handleUp);
|
|
};
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
|
|
const selectedKf = keyframes.find((k) => k.id === selectedId);
|
|
|
|
const content = (
|
|
<div className="space-y-2">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
Keyframes
|
|
</span>
|
|
<span className="text-[9px] text-muted-foreground/40 font-mono">
|
|
{keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => onAdd(50)} title="Add at 50%" className={timelineBtn}>
|
|
<Plus className="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => selectedId && onDelete(selectedId)}
|
|
disabled={!selectedId || keyframes.length <= 2}
|
|
title="Delete selected"
|
|
className={timelineBtn}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Track */}
|
|
<div
|
|
ref={trackRef}
|
|
className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none mx-4"
|
|
onClick={handleTrackClick}
|
|
>
|
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
|
|
{TICKS.map((tick) => (
|
|
<div
|
|
key={tick}
|
|
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none -ml-1.5"
|
|
style={{ left: `${tick}%` }}
|
|
>
|
|
<div className="w-px h-2 bg-muted-foreground/20" />
|
|
<span className="text-[8px] text-muted-foreground/30 mt-auto mb-1 font-mono">{tick}%</span>
|
|
</div>
|
|
))}
|
|
{sorted.map((kf) => (
|
|
<button
|
|
key={kf.id}
|
|
data-keyframe-marker
|
|
className={cn(
|
|
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rotate-45 rounded-sm transition-all duration-150 touch-none',
|
|
kf.id === selectedId
|
|
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
|
|
: 'bg-muted-foreground/40 hover:bg-primary/70'
|
|
)}
|
|
style={{ left: `${kf.offset}%` }}
|
|
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
|
|
onPointerDown={(e) => handlePointerDown(e, kf.id)}
|
|
title={`${kf.offset}% — drag to move`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Offset labels */}
|
|
<div className="relative h-4 mx-4">
|
|
{sorted.map((kf) => (
|
|
<span
|
|
key={kf.id}
|
|
className={cn(
|
|
'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
|
|
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
|
|
)}
|
|
style={{ left: `${kf.offset}%` }}
|
|
>
|
|
{kf.offset}%
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (embedded) return <div>{content}</div>;
|
|
|
|
return (
|
|
<div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|