@@ -11,6 +11,24 @@
import React , { useState , useRef , useCallback , useEffect , useLayoutEffect , useMemo } from "react"
import type { ClipData , ClipType , TrimConfig } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import {
CLIP_TYPE_ICONS ,
CLIP_TYPE_LABELS ,
DEFAULT_PIXELS_PER_SECOND ,
MIN_PIXELS_PER_SECOND ,
MAX_PIXELS_PER_SECOND ,
ZOOM_STEP ,
MIN_CLIP_WIDTH ,
ADD_PICKER_WIDTH ,
TRACK_GAP ,
MIN_TRIM_DURATION ,
DEFAULT_ADD_DURATION ,
MIN_ADD_DURATION ,
MAX_ADD_DURATION ,
MIN_TRACK_WIDTH ,
getRulerStep ,
} from "../constants/timeline"
import { formatTime , formatTrimTime , generateRulerMarks } from "../utils/timeline"
interface TimelinePanelProps {
clips : ClipData [ ]
@@ -38,18 +56,6 @@ interface TimelinePanelProps {
totalDuration? : number
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS : Record < ClipType , string > = {
voice : "🎙️" ,
pip : "🖼️" ,
}
/** 片段类型标签 */
const CLIP_TYPE_LABELS : Record < ClipType , string > = {
voice : "口播" ,
pip : "混剪" ,
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
@@ -135,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── "+" 卡片:类型+时长选择状态 ── */
const [ addType , setAddType ] = useState < ClipType > ( defaultAddType )
const [ addDuration , setAddDuration ] = useState < number > ( 5 )
const [ addDuration , setAddDuration ] = useState < number > ( DEFAULT_ADD_DURATION )
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect ( ( ) = > {
@@ -144,21 +150,17 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}
} , [ currentMode , addType , availableTypes , defaultAddType ] )
/* ── 面板尺寸 ── */
const PICKER_W = 240
const GAP = 6
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback ( ( ) = > {
if ( ! addCardRef . current ) return
const rect = addCardRef . current . getBoundingClientRect ( )
const vw = window . innerWidth
const roughHeight = 180
let top = rect . top - GAP - roughHeight
let top = rect . top - TRACK_GAP - roughHeight
if ( top < 8 ) top = 8
let right = vw - rect . right
if ( rect . right - PICKER_W < 8 ) {
right = vw - PICKER_W - 8
if ( rect . right - ADD_PICKER_WIDTH < 8 ) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos ( { top , right } )
} , [ ] )
@@ -181,9 +183,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const pickerH = pickerEl . offsetHeight
const vh = window . innerHeight
const vw = window . innerWidth
let top = addRect . top - GAP - pickerH
let top = addRect . top - TRACK_GAP - pickerH
if ( top < 8 ) {
top = addRect . bottom + GAP
top = addRect . bottom + TRACK_GAP
if ( top + pickerH > vh - 8 ) {
top = vh - 8 - pickerH
if ( top < 8 ) top = 8
@@ -192,7 +194,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
let right = vw - addRect . right
const pickerRect = pickerEl . getBoundingClientRect ( )
if ( pickerRect . left < 8 ) {
right = vw - PICKER_W - 8
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos ( { top , right } )
} , [ showAddPicker ] )
@@ -224,7 +226,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
} , [ contextMenu ] )
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ? ? 40
const pps = pixelsPerSecond ? ? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ? ? clips . reduce ( ( s , c ) = > s + c . duration , 0 )
/* ── 播放头拖拽全局 mousemove/mouseup ── */
@@ -344,11 +346,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
useEffect ( ( ) = > {
if ( ! trimDrag ) return
const PX_PER_SECOND = pixelsPerSecond ? ? 40 // 与缩放级别同步
const handleMouseMove = ( e : MouseEvent ) = > {
const dx = e . clientX - trimDrag . startX
const dtSec = dx / PX_PER_SECOND
const dtSec = dx / pps
const clip = clips . find ( ( c ) = > c . id === trimDrag . clipId )
if ( ! clip ) return
@@ -359,10 +359,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if ( trimDrag . direction === "left" ) {
// 左手柄:调整入点
newStart = Math . max ( 0 , Math . min ( origTrim . start_time + dtSec , newEnd - 1 ) )
newStart = Math . max ( 0 , Math . min ( origTrim . start_time + dtSec , newEnd - MIN_TRIM_DURATION ) )
} else {
// 右手柄:调整出点
newEnd = Math . max ( origTrim . start_time + 1 , Math . min ( origTrim . end_time + dtSec , origDur ) )
newEnd = Math . max (
origTrim . start_time + MIN_TRIM_DURATION ,
Math . min ( origTrim . end_time + dtSec , origDur ) ,
)
}
const newDuration = Math . round ( ( newEnd - newStart ) * 10 ) / 10
@@ -396,7 +399,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
document . removeEventListener ( "mousemove" , handleMouseMove )
document . removeEventListener ( "mouseup" , handleMouseUp )
}
} , [ trimDrag , trimPreview , clips , onClipTrim , pixelsPerSecond ] )
} , [ trimDrag , trimPreview , clips , onClipTrim , pps ] )
/* ── 右键菜单 ── */
const handleContextMenu = useCallback ( ( e : React.MouseEvent , clipId : string ) = > {
@@ -429,23 +432,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
} , [ contextMenu , onClipRemove ] )
/* ── 时间标尺 ── */
const trackWidth = Math . max ( totalDuration * pps , 300 )
const rulerMarks : number [ ] = [ ]
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
for ( let t = 0 ; t <= totalDuration + step ; t += step ) {
rulerMarks . push ( t )
}
const formatTime = ( sec : number ) = > {
const m = Math . floor ( sec / 60 )
const s = Math . floor ( sec % 60 )
return ` ${ m } : ${ s . toString ( ) . padStart ( 2 , "0" ) } `
}
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = ( sec : number ) = > {
return ` ${ sec . toFixed ( 1 ) } s `
}
const trackWidth = Math . max ( totalDuration * pps , MIN_TRACK_WIDTH )
const step = getRulerStep ( totalDuration )
const rulerMarks = generateRulerMarks ( totalDuration , step )
return (
< div className = "ep-timeline-area" >
@@ -460,7 +449,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
< div className = "ep-timeline-zoom" >
< button
className = "ep-zoom-btn"
onClick = { ( ) = > onZoomChange ? . ( Math . max ( 10 , pps - 10 ) ) }
onClick = { ( ) = > onZoomChange ? . ( Math . max ( MIN_PIXELS_PER_SECOND , pps - ZOOM_STEP ) ) }
title = "缩小"
>
−
@@ -468,15 +457,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
< input
type = "range"
className = "ep-zoom-slider"
min = { 10 }
max = { 120 }
min = { MIN_PIXELS_PER_SECOND }
max = { MAX_PIXELS_PER_SECOND }
step = { 5 }
value = { pps }
onChange = { ( e ) = > onZoomChange ? . ( Number ( e . target . value ) ) }
/ >
< button
className = "ep-zoom-btn"
onClick = { ( ) = > onZoomChange ? . ( Math . min ( 120 , pps + 10 ) ) }
onClick = { ( ) = > onZoomChange ? . ( Math . min ( MAX_PIXELS_PER_SECOND , pps + ZOOM_STEP ) ) }
title = "放大"
>
+
@@ -567,7 +556,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
< div
className = { ` ep-clip-card ${ selectedClipId === clip . id ? "selected" : "" } ${ dragIdx === idx ? "dragging" : "" } ${ dragOverIdx === idx ? "drag-over" : "" } ${ hasTrim ? "trimmed" : "" } ` }
style = { { width : Math.max ( clip . duration * pps , 60 ) } }
style = { { width : Math.max ( clip . duration * pps , MIN_CLIP_WIDTH ) } }
draggable = { ! trimDrag }
onDragStart = { ( e ) = > handleDragStart ( e , idx ) }
onDragOver = { ( e ) = > handleDragOver ( e , idx ) }
@@ -751,11 +740,16 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
< input
type = "number"
className = "ep-duration-input"
min = { 1 }
max = { 120 }
min = { MIN_ADD_DURATION }
max = { MAX_ADD_DURATION }
value = { addDuration }
onChange = { ( e ) = >
setAddDuration ( Math . max ( 1 , Math . min ( 120 , Number ( e . target . value ) || 1 ) ) )
setAddDuration (
Math . max (
MIN_ADD_DURATION ,
Math . min ( MAX_ADD_DURATION , Number ( e . target . value ) || MIN_ADD_DURATION ) ,
) ,
)
}
/ >
< span className = "ep-add-clip-duration-unit" > 秒 < / span >