/** * 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单 * 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序 * "+" 卡片 → 类型+时长选择器 * * 裁剪交互: * - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点 * - 拖动时实时显示裁剪预览(入点/出点/时长) * - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除 */ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react" import type { ClipData, ClipType, TrimConfig } from "../types" import { DEFAULT_PIXELS_PER_SECOND, MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND, ZOOM_STEP, MIN_TRIM_DURATION, DEFAULT_ADD_DURATION, MIN_ADD_DURATION, MAX_ADD_DURATION, TRACK_GAP, ADD_PICKER_WIDTH, } from "../constants/timeline" import { formatTime } from "../utils/timeline" import { ClipCard } from "./timeline/ClipCard" import { TimeRuler } from "./timeline/TimeRuler" import { AddClipPicker } from "./timeline/AddClipPicker" import { TrimPreview } from "./timeline/TrimPreview" import { ContextMenu } from "./timeline/ContextMenu" interface TimelinePanelProps { clips: ClipData[] selectedClipId: string | null currentMode: string onClipSelect: (clipId: string) => void onClipReorder: (fromIdx: number, toIdx: number) => void onClipRemove: (clipId: string) => void onAddClip: (type: ClipType, duration: number) => void /** 裁剪更新:调整片段的 trim_config 和 duration */ onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void /** 在指定位置分割片段 */ onClipSplit?: (clipId: string, splitRatio: number) => void /** 恢复片段原始长度 */ onClipResetTrim?: (clipId: string) => void /** 当前播放时间(秒) */ currentTime?: number /** 缩放:每秒像素数 */ pixelsPerSecond?: number /** 缩放变更回调 */ onZoomChange?: (pps: number) => void /** 播放头跳转回调 */ onSeek?: (time: number) => void /** 总时长(秒),可选(默认由 clips 计算) */ totalDuration?: number } /** 裁剪拖拽方向 */ type TrimDirection = "left" | "right" /** 裁剪拖拽状态 */ interface TrimDragState { clipId: string direction: TrimDirection startX: number originalTrim: TrimConfig originalDuration: number } /** 右键菜单状态 */ interface ContextMenuState { x: number y: number clipId: string } const TimelinePanel: React.FC = ({ clips, selectedClipId, currentMode, onClipSelect, onClipReorder, onClipRemove, onAddClip, onClipTrim, onClipSplit, onClipResetTrim, currentTime = 0, pixelsPerSecond = 40, onZoomChange, onSeek, totalDuration: totalDurationProp, }) => { const [dragIdx, setDragIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null) const dragRef = useRef(null) const [showAddPicker, setShowAddPicker] = useState(false) const pickerRef = useRef(null) const addCardRef = useRef(null) const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0, }) /* ── 裁剪拖拽状态 ── */ const [trimDrag, setTrimDrag] = useState(null) const [trimPreview, setTrimPreview] = useState<{ clipId: string startTime: number endTime: number duration: number x: number y: number } | null>(null) /* ── 右键菜单 ── */ const [contextMenu, setContextMenu] = useState(null) const contextMenuRef = useRef(null) /* ── 悬停的片段 ID(显示裁剪手柄) ── */ const [hoveredClipId, setHoveredClipId] = useState(null) /* ── 播放头拖拽状态 ── */ const [playheadDragging, setPlayheadDragging] = useState(false) const trackRef = useRef(null) /* ── 根据模式决定可选类型 ── */ const availableTypes: ClipType[] = useMemo( () => currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认 [currentMode], ) /* ── 默认添加类型:跟随模式 ── */ const defaultAddType: ClipType = useMemo(() => { if (currentMode === "voice_over") return "voice" if (currentMode === "pip") return "pip" return "voice" }, [currentMode]) /* ── "+" 卡片:类型+时长选择状态 ── */ const [addType, setAddType] = useState(defaultAddType) const [addDuration, setAddDuration] = useState(DEFAULT_ADD_DURATION) /* ── 模式切换时自动同步默认添加类型 ── */ useEffect(() => { if (!availableTypes.includes(addType)) { setAddType(defaultAddType) } }, [currentMode, addType, availableTypes, defaultAddType]) /* ── 计算 picker 初始位置 ── */ const updatePickerPosition = useCallback(() => { if (!addCardRef.current) return const rect = addCardRef.current.getBoundingClientRect() const vw = window.innerWidth const roughHeight = 180 let top = rect.top - TRACK_GAP - roughHeight if (top < 8) top = 8 let right = vw - rect.right if (rect.right - ADD_PICKER_WIDTH < 8) { right = vw - ADD_PICKER_WIDTH - 8 } setPickerPos({ top, right }) }, []) const handleTogglePicker = () => { if (!showAddPicker) { const defaultType = currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice" setAddType(defaultType) updatePickerPosition() } setShowAddPicker((v) => !v) } /* ── 渲染后精确边界校正 ── */ useLayoutEffect(() => { if (!showAddPicker || !pickerRef.current || !addCardRef.current) return const pickerEl = pickerRef.current const addRect = addCardRef.current.getBoundingClientRect() const pickerH = pickerEl.offsetHeight const vh = window.innerHeight const vw = window.innerWidth let top = addRect.top - TRACK_GAP - pickerH if (top < 8) { top = addRect.bottom + TRACK_GAP if (top + pickerH > vh - 8) { top = vh - 8 - pickerH if (top < 8) top = 8 } } let right = vw - addRect.right const pickerRect = pickerEl.getBoundingClientRect() if (pickerRect.left < 8) { right = vw - ADD_PICKER_WIDTH - 8 } setPickerPos({ top, right }) }, [showAddPicker]) /* ── 点击外部关闭添加面板 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { setShowAddPicker(false) } } if (showAddPicker) { document.addEventListener("mousedown", handleClickOutside) } return () => document.removeEventListener("mousedown", handleClickOutside) }, [showAddPicker]) /* ── 点击外部关闭右键菜单 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { setContextMenu(null) } } if (contextMenu) { document.addEventListener("mousedown", handleClickOutside) } return () => document.removeEventListener("mousedown", handleClickOutside) }, [contextMenu]) /* ── 缩放 & 时长 ── */ const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0) /* ── 播放头拖拽全局 mousemove/mouseup ── */ useEffect(() => { if (!playheadDragging) return const handleMouseMove = (e: MouseEvent) => { const trackEl = trackRef.current if (!trackEl) return const rect = trackEl.getBoundingClientRect() const x = e.clientX - rect.left + trackEl.scrollLeft const time = Math.max(0, Math.min(x / pps, totalDuration)) onSeek?.(Math.round(time * 10) / 10) } const handleMouseUp = () => { setPlayheadDragging(false) } document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) return () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } }, [playheadDragging, pps, totalDuration, onSeek]) /* ── 标尺点击跳转播放头 ── */ const handleRulerClick = useCallback( (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left const time = Math.max(0, Math.min(x / pps, totalDuration)) onSeek?.(Math.round(time * 10) / 10) }, [pps, totalDuration, onSeek], ) /* ── 播放头拖拽开始 ── */ const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setPlayheadDragging(true) }, []) /* ── 确认添加片段 ── */ const handleConfirmAdd = () => { onAddClip(addType, addDuration) setShowAddPicker(false) } /* ── 片段拖拽排序 ── */ const handleDragStart = (e: React.DragEvent, idx: number) => { // 如果正在裁剪拖拽,不允许排序拖拽 if (trimDrag) return dragRef.current = idx setDragIdx(idx) e.dataTransfer.setData("application/x-clip-drag", String(idx)) e.dataTransfer.effectAllowed = "move" } const handleDragOver = (e: React.DragEvent, idx: number) => { e.preventDefault() e.dataTransfer.dropEffect = "move" setDragOverIdx(idx) } const handleDragEnd = () => { dragRef.current = null setDragIdx(null) setDragOverIdx(null) } const handleDrop = (e: React.DragEvent, toIdx: number) => { e.preventDefault() setDragOverIdx(null) const fromStr = e.dataTransfer.getData("application/x-clip-drag") if (fromStr !== "") { const fromIdx = Number(fromStr) if (fromIdx !== toIdx) { onClipReorder(fromIdx, toIdx) } } } /* ── 空轨道区域不接受素材拖入 ── */ const handleEmptyDragOver = (e: React.DragEvent) => { e.preventDefault() } /* ── 裁剪手柄拖拽 ── */ const handleTrimHandleMouseDown = useCallback( (e: React.MouseEvent, clipId: string, direction: TrimDirection) => { e.preventDefault() e.stopPropagation() const clip = clips.find((c) => c.id === clipId) if (!clip) return const trim: TrimConfig = clip.trim_config ?? { start_time: 0, end_time: clip.duration, original_duration: clip.duration, } setTrimDrag({ clipId, direction, startX: e.clientX, originalTrim: { ...trim }, originalDuration: clip.duration, }) }, [clips], ) /* ── 裁剪拖拽全局 mousemove/mouseup ── */ useEffect(() => { if (!trimDrag) return const handleMouseMove = (e: MouseEvent) => { const dx = e.clientX - trimDrag.startX const dtSec = dx / pps const clip = clips.find((c) => c.id === trimDrag.clipId) if (!clip) return const origTrim = trimDrag.originalTrim const origDur = origTrim.original_duration ?? trimDrag.originalDuration let newStart = origTrim.start_time let newEnd = origTrim.end_time if (trimDrag.direction === "left") { // 左手柄:调整入点 newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION)) } else { // 右手柄:调整出点 newEnd = Math.max( origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur), ) } const newDuration = Math.round((newEnd - newStart) * 10) / 10 setTrimPreview({ clipId: trimDrag.clipId, startTime: Math.round(newStart * 10) / 10, endTime: Math.round(newEnd * 10) / 10, duration: newDuration, x: e.clientX, y: e.clientY, }) } const handleMouseUp = () => { if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) { const newTrim: TrimConfig = { start_time: trimPreview.startTime, end_time: trimPreview.endTime, original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration, } onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration) } setTrimDrag(null) setTrimPreview(null) } document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) return () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } }, [trimDrag, trimPreview, clips, onClipTrim, pps]) /* ── 右键菜单 ── */ const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => { e.preventDefault() e.stopPropagation() setContextMenu({ x: e.clientX, y: e.clientY, clipId }) }, []) /* ── 右键菜单操作 ── */ const handleContextSplit = useCallback(() => { if (!contextMenu) return if (onClipSplit) { onClipSplit(contextMenu.clipId, 0.5) // 在中间分割 } setContextMenu(null) }, [contextMenu, onClipSplit]) const handleContextResetTrim = useCallback(() => { if (!contextMenu) return if (onClipResetTrim) { onClipResetTrim(contextMenu.clipId) } setContextMenu(null) }, [contextMenu, onClipResetTrim]) const handleContextDelete = useCallback(() => { if (!contextMenu) return onClipRemove(contextMenu.clipId) setContextMenu(null) }, [contextMenu, onClipRemove]) return (
{/* 时间线头部 */}
🎬 时间线 总时长: {formatTime(totalDuration)}
{/* 缩放控件 */}
onZoomChange?.(Number(e.target.value))} /> {pps}px/s
{/* 一镜到底提示 */} {currentMode === "one_take" &&
🎥 一镜到底模式无片段
} {/* 时间标尺 */} {currentMode !== "one_take" && ( )} {/* 水平片段轨道 */} {currentMode !== "one_take" && (
{/* 播放头 */} {currentMode !== "one_take" && totalDuration > 0 && (
)} {clips.length === 0 ? (
🎬
点击右侧 + 添加片段
) : ( clips.map((clip, idx) => ( setHoveredClipId(clip.id)} onMouseLeave={() => setHoveredClipId(null)} onTrimHandleMouseDown={handleTrimHandleMouseDown} onRemove={onClipRemove} /> )) )} {/* ── 轨道末尾 "+" 添加卡片 ── */}
+
)} {/* 裁剪预览 tooltip */} {trimPreview && ( )} {/* 右键菜单 */} {contextMenu && ( c.id === contextMenu.clipId)?.trim_config} onSplit={handleContextSplit} onResetTrim={handleContextResetTrim} onDelete={handleContextDelete} /> )} {/* 类型+时长选择面板 */} {showAddPicker && ( )}
) } export default TimelinePanel