/** * TrackContent - Содержимое трека (клипы на временной шкале) */ import { useDroppable } from "@dnd-kit/core" import { memo, useCallback, useMemo } from "react" import { useDropZone } from "@/features/drag-drop" import { cn } from "@/lib/utils" import { useClipGroups } from "../../hooks/use-clip-groups" import { useDragDropTimeline } from "../../hooks/use-drag-drop-timeline" import { useTimeline } from "../../hooks/use-timeline" import { useTrackTransitionCollisions } from "../../hooks/use-transition-collisions" import { addTransitionBetweenClips, getTrackTransitions } from "../../services/timeline-transition-manager" import { TimelineTrack } from "../../types" import { Clip } from "../clip/clip" import { CollapsedGroup } from "../clip-groups/collapsed-group" import { TimelineTransitionComponent } from "../transition/timeline-transition" import { TransitionCollisionIndicator } from "../transition/transition-collision-indicator" import { TransitionDropZone } from "../transition/transition-drop-zone" import { TrackRollHandles } from "./track-roll-handles" interface TrackContentProps { track: TimelineTrack timeScale: number // Пикселей на секунду currentTime: number onUpdate?: (updates: Partial) => void } export const TrackContent = memo(function TrackContent({ track, timeScale, currentTime, onUpdate }: TrackContentProps) { const { dragState, isValidDropTarget } = useDragDropTimeline() const { addClip, selectClips, project, updateProject } = useTimeline() const { groups, toggleCollapse } = useClipGroups() // Обнаружение коллизий переходов на треке const collisions = useTrackTransitionCollisions(track.id) // Setup droppable functionality for @dnd-kit const { isOver, setNodeRef } = useDroppable({ id: `track-${track.id}`, data: { trackId: track.id, trackType: track.type, }, }) // Register as drop zone for global DragDropManager const acceptedTypes: Array<"media" | "music"> = track.type === "video" ? ["media"] : track.type === "audio" ? ["media", "music"] : [] const { ref: dropZoneRef } = useDropZone(`track-${track.id}`, acceptedTypes, (item, event) => { // Calculate drop position based on mouse position const rect = dropZoneRef.current?.getBoundingClientRect() if (!rect) return const x = event.clientX - rect.left const dropTime = x / timeScale // Add clip to timeline if (item.type === "media" || item.type === "music") { void addClip(track.id, item.data, dropTime) } }) // Check if this track is a valid drop target const isValidTarget = isValidDropTarget(track.id, track.type) const showDropFeedback = dragState.isDragging && isOver && isValidTarget // Получаем переходы для этого трека const trackTransitions = useMemo(() => { if (!project) return [] return getTrackTransitions(project, track.id) }, [project, track.id]) // Мемоизируем отсортированные клипы и группы const { visibleClips, collapsedGroups } = useMemo(() => { const visibleClips: typeof track.clips = [] const collapsedGroups: Array<{ group: any; clips: typeof track.clips }> = [] // Находим группы, которые содержат клипы с этого трека const trackGroups = groups.filter((group) => group.clips.some((ref) => track.clips.some((clip) => clip.id === ref.clipId)), ) // Разделяем клипы на видимые и принадлежащие свернутым группам track.clips.forEach((clip) => { const group = trackGroups.find((g) => g.clips.some((ref) => ref.clipId === clip.id)) if (group && group.collapsed) { // Клип в свернутой группе let groupEntry = collapsedGroups.find((cg) => cg.group.id === group.id) if (!groupEntry) { groupEntry = { group, clips: [] } collapsedGroups.push(groupEntry) } groupEntry.clips.push(clip) } else { // Видимый клип (не в группе или группа развернута) visibleClips.push(clip) } }) // Сортируем видимые клипы visibleClips.sort((a, b) => a.startTime - b.startTime) return { visibleClips, collapsedGroups } }, [track.clips, groups]) // Мемоизируем обработчики для предотвращения создания новых функций const handleClipUpdate = useCallback( (clipId: string, updates: any) => { const updatedClips = track.clips.map((clip) => (clip.id === clipId ? { ...clip, ...updates } : clip)) onUpdate?.({ clips: updatedClips }) }, [track.clips, onUpdate], ) const handleClipRemove = useCallback( (clipId: string) => { const updatedClips = track.clips.filter((clip) => clip.id !== clipId) onUpdate?.({ clips: updatedClips }) }, [track.clips, onUpdate], ) // Обработчик добавления перехода между клипами const handleTransitionDrop = useCallback( (leftClipId: string, rightClipId: string, transition: any) => { if (!project) return try { const result = addTransitionBetweenClips(project, track.id, leftClipId, rightClipId, transition) updateProject?.(result.project) } catch (error) { console.error("Failed to add transition:", error) } }, [project, track.id, updateProject], ) // Мемоизируем сетку временной шкалы const gridLines = useMemo(() => { const lines = [] const maxTime = 300 // 5 минут const step = 10 // каждые 10 секунд for (let time = 0; time <= maxTime; time += step) { lines.push(
, ) } return lines }, [timeScale]) return (
{ setNodeRef(el) if (dropZoneRef.current !== el) { dropZoneRef.current = el } }} data-track-id={track.id} data-testid={`track-container-${track.id}`} className={cn( "relative h-full w-full", "bg-background border-l border-border", showDropFeedback && "bg-primary/10 border-primary", dragState.isDragging && isValidTarget && "border-dashed border-2", dragState.isDragging && !isValidTarget && "opacity-50", )} > {/* Drop zone visual feedback */} {showDropFeedback && (
Drop here
)} {/* Insertion indicator */} {dragState.dropPosition?.trackId === track.id && (
)} {/* Сетка временной шкалы */}
{gridLines}
{/* Playhead (указатель текущего времени) */}
{/* Клипы и свернутые группы */}
{/* Отображаем видимые клипы */} {visibleClips.map((clip) => ( handleClipUpdate(clip.id, updates)} onRemove={() => handleClipRemove(clip.id)} /> ))} {/* Отображаем свернутые группы */} {collapsedGroups.map(({ group, clips }) => ( toggleCollapse(group.id)} onSelect={() => selectClips(clips.map((c) => c.id))} isSelected={clips.some((c) => c.isSelected)} /> ))} {/* Переходы */} {trackTransitions.map((transition) => ( { // Обновление перехода через контекст или API console.log("Transition update:", transition.id, updates) }} onDelete={() => { // Удаление перехода console.log("Transition delete:", transition.id) }} /> ))} {/* Зоны для сброса переходов между клипами */} {visibleClips.length > 1 && visibleClips.slice(0, -1).map((leftClip, index) => { const rightClip = visibleClips[index + 1] return ( handleTransitionDrop(leftClip.id, rightClip.id, transition)} /> ) })} {/* Roll edit handles between adjacent clips */} { // This would typically trigger a roll edit operation console.log("Roll edit started:", leftClipId, rightClipId, mouseX) }} />
{/* Индикатор коллизий переходов */} {collisions.length > 0 && (
{ console.log("Resolve collision:", collision) // TODO: Интегрировать с системой исправления коллизий }} />
)} {/* Область для добавления клипов (drop zone) */} {track.clips.length === 0 && (
Перетащите медиафайлы сюда
или используйте Browser
)}
) })