Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 204f9462ec | |||
| 7f5bfae234 | |||
| 9df36da1bd | |||
| b45531e4db | |||
| e85f79c739 | |||
| 60dc6dbb3c |
@@ -8,21 +8,13 @@
|
|||||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||||
*/
|
*/
|
||||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||||
import {
|
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||||||
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 { formatTime } from "../utils/timeline"
|
||||||
|
import { useClipDrag } from "../hooks/useClipDrag"
|
||||||
|
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||||
|
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||||
import { ClipCard } from "./timeline/ClipCard"
|
import { ClipCard } from "./timeline/ClipCard"
|
||||||
import { TimeRuler } from "./timeline/TimeRuler"
|
import { TimeRuler } from "./timeline/TimeRuler"
|
||||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||||
@@ -55,25 +47,6 @@ interface TimelinePanelProps {
|
|||||||
totalDuration?: number
|
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<TimelinePanelProps> = ({
|
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||||
clips,
|
clips,
|
||||||
selectedClipId,
|
selectedClipId,
|
||||||
@@ -91,143 +64,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
onSeek,
|
onSeek,
|
||||||
totalDuration: totalDurationProp,
|
totalDuration: totalDurationProp,
|
||||||
}) => {
|
}) => {
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
/* ── 缩放 & 时长 ── */
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||||
const dragRef = useRef<number | null>(null)
|
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
|
||||||
const pickerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const addCardRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 裁剪拖拽状态 ── */
|
/* ── 裁剪拖拽 ── */
|
||||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||||
const [trimPreview, setTrimPreview] = useState<{
|
|
||||||
clipId: string
|
|
||||||
startTime: number
|
|
||||||
endTime: number
|
|
||||||
duration: number
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
/* ── 右键菜单 ── */
|
/* ── 片段拖拽排序 ── */
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
const {
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDrop,
|
||||||
|
handleEmptyDragOver,
|
||||||
|
} = useClipDrag(onClipReorder, !!trimDrag)
|
||||||
|
|
||||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
/* ── 菜单 & 面板 ── */
|
||||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
const {
|
||||||
|
contextMenu,
|
||||||
|
contextMenuRef,
|
||||||
|
handleContextMenu,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
showAddPicker,
|
||||||
|
pickerRef,
|
||||||
|
addCardRef,
|
||||||
|
pickerPos,
|
||||||
|
availableTypes,
|
||||||
|
addType,
|
||||||
|
addDuration,
|
||||||
|
setAddType,
|
||||||
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
|
handleConfirmAdd,
|
||||||
|
hoveredClipId,
|
||||||
|
setHoveredClipId,
|
||||||
|
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||||
|
|
||||||
/* ── 播放头拖拽状态 ── */
|
/* ── 播放头拖拽状态 ── */
|
||||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||||
const trackRef = useRef<HTMLDivElement>(null)
|
const trackRef = useRef<HTMLDivElement>(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<ClipType>(defaultAddType)
|
|
||||||
const [addDuration, setAddDuration] = useState<number>(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 ── */
|
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playheadDragging) return
|
if (!playheadDragging) return
|
||||||
@@ -271,165 +152,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
setPlayheadDragging(true)
|
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 (
|
return (
|
||||||
<div className="ep-timeline-area">
|
<div className="ep-timeline-area">
|
||||||
{/* 时间线头部 */}
|
{/* 时间线头部 */}
|
||||||
@@ -443,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
<div className="ep-timeline-zoom">
|
<div className="ep-timeline-zoom">
|
||||||
<button
|
<button
|
||||||
className="ep-zoom-btn"
|
className="ep-zoom-btn"
|
||||||
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
|
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||||
title="缩小"
|
title="缩小"
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
@@ -451,15 +173,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="ep-zoom-slider"
|
className="ep-zoom-slider"
|
||||||
min={MIN_PIXELS_PER_SECOND}
|
min={10}
|
||||||
max={MAX_PIXELS_PER_SECOND}
|
max={120}
|
||||||
step={5}
|
step={5}
|
||||||
value={pps}
|
value={pps}
|
||||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="ep-zoom-btn"
|
className="ep-zoom-btn"
|
||||||
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
|
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||||
title="放大"
|
title="放大"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -583,8 +305,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
availableTypes={availableTypes}
|
availableTypes={availableTypes}
|
||||||
addType={addType}
|
addType={addType}
|
||||||
addDuration={addDuration}
|
addDuration={addDuration}
|
||||||
minDuration={MIN_ADD_DURATION}
|
|
||||||
maxDuration={MAX_ADD_DURATION}
|
|
||||||
onTypeChange={setAddType}
|
onTypeChange={setAddType}
|
||||||
onDurationChange={setAddDuration}
|
onDurationChange={setAddDuration}
|
||||||
onConfirm={handleConfirmAdd}
|
onConfirm={handleConfirmAdd}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 片段拖拽排序 Hook
|
||||||
|
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
|
||||||
|
*/
|
||||||
|
export const useClipDrag = (
|
||||||
|
onClipReorder: (fromIdx: number, toIdx: number) => void,
|
||||||
|
disabled?: boolean,
|
||||||
|
) => {
|
||||||
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
if (disabled) return
|
||||||
|
setDragIdx(idx)
|
||||||
|
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
},
|
||||||
|
[disabled],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = "move"
|
||||||
|
setDragOverIdx(idx)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDragIdx(null)
|
||||||
|
},
|
||||||
|
[onClipReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDrop,
|
||||||
|
handleEmptyDragOver,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||||
|
import type { ClipData, ClipType } from "../types"
|
||||||
|
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
clipId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间线菜单 Hook
|
||||||
|
* 管理右键菜单和添加片段面板的状态与交互
|
||||||
|
*/
|
||||||
|
export const useTimelineMenus = (
|
||||||
|
_clips: ClipData[],
|
||||||
|
currentMode: string,
|
||||||
|
onAddClip: (type: ClipType, duration: number) => void,
|
||||||
|
onClipSplit?: (clipId: string, splitRatio: number) => void,
|
||||||
|
onClipResetTrim?: (clipId: string) => void,
|
||||||
|
onClipRemove?: (clipId: string) => void,
|
||||||
|
) => {
|
||||||
|
/* ── 右键菜单 ── */
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
/* ── 添加片段面板 ── */
|
||||||
|
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const addCardRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||||
|
|
||||||
|
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||||
|
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* ── 根据模式决定可选类型 ── */
|
||||||
|
const availableTypes: ClipType[] = useMemo(
|
||||||
|
() =>
|
||||||
|
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||||
|
[currentMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── 默认添加类型:跟随模式 ── */
|
||||||
|
const defaultAddType: ClipType = useMemo(() => {
|
||||||
|
if (currentMode === "voice_over") return "voice"
|
||||||
|
if (currentMode === "pip") return "pip"
|
||||||
|
return "voice"
|
||||||
|
}, [currentMode])
|
||||||
|
|
||||||
|
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||||
|
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||||
|
const [addDuration, setAddDuration] = useState<number>(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 = useCallback(() => {
|
||||||
|
if (!showAddPicker) {
|
||||||
|
setAddType(defaultAddType)
|
||||||
|
updatePickerPosition()
|
||||||
|
}
|
||||||
|
setShowAddPicker((v) => !v)
|
||||||
|
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||||
|
|
||||||
|
/* ── 渲染后精确边界校正 ── */
|
||||||
|
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 handleConfirmAdd = useCallback(() => {
|
||||||
|
onAddClip(addType, addDuration)
|
||||||
|
setShowAddPicker(false)
|
||||||
|
}, [onAddClip, addType, addDuration])
|
||||||
|
|
||||||
|
/* ── 右键菜单操作 ── */
|
||||||
|
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
|
||||||
|
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipSplit])
|
||||||
|
|
||||||
|
const handleContextResetTrim = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipResetTrim?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipResetTrim])
|
||||||
|
|
||||||
|
const handleContextDelete = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipRemove?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipRemove])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 右键菜单
|
||||||
|
contextMenu,
|
||||||
|
contextMenuRef,
|
||||||
|
handleContextMenu,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
// 添加面板
|
||||||
|
showAddPicker,
|
||||||
|
pickerRef,
|
||||||
|
addCardRef,
|
||||||
|
pickerPos,
|
||||||
|
availableTypes,
|
||||||
|
addType,
|
||||||
|
addDuration,
|
||||||
|
setAddType,
|
||||||
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
|
handleConfirmAdd,
|
||||||
|
// 悬停状态
|
||||||
|
hoveredClipId,
|
||||||
|
setHoveredClipId,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react"
|
||||||
|
import type { ClipData, TrimConfig } from "../types"
|
||||||
|
|
||||||
|
interface TrimDragState {
|
||||||
|
clipId: string
|
||||||
|
direction: "left" | "right"
|
||||||
|
startX: number
|
||||||
|
originalTrim: TrimConfig
|
||||||
|
originalDuration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrimPreviewState {
|
||||||
|
clipId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
duration: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_TRIM_DURATION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪拖拽 Hook
|
||||||
|
* 拖拽片段两端手柄调整入点/出点,实时显示预览
|
||||||
|
*/
|
||||||
|
export const useTrimDrag = (
|
||||||
|
clips: ClipData[],
|
||||||
|
pps: number,
|
||||||
|
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
|
||||||
|
) => {
|
||||||
|
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||||
|
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
|
||||||
|
|
||||||
|
const handleTrimHandleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
|
||||||
|
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, pps, onClipTrim])
|
||||||
|
|
||||||
|
return {
|
||||||
|
trimDrag,
|
||||||
|
trimPreview,
|
||||||
|
handleTrimHandleMouseDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,9 @@ import "@/pages/editing-planner/hooks/usePipLayers"
|
|||||||
import "@/pages/editing-planner/hooks/useStickerItems"
|
import "@/pages/editing-planner/hooks/useStickerItems"
|
||||||
import "@/pages/editing-planner/hooks/useTtsPanel"
|
import "@/pages/editing-planner/hooks/useTtsPanel"
|
||||||
import "@/pages/editing-planner/hooks/useWatermarkConfig"
|
import "@/pages/editing-planner/hooks/useWatermarkConfig"
|
||||||
|
import "@/pages/editing-planner/hooks/useTimelineMenus"
|
||||||
|
import "@/pages/editing-planner/hooks/useTrimDrag"
|
||||||
|
import "@/pages/editing-planner/hooks/useClipDrag"
|
||||||
|
|
||||||
describe("EditingPlanner module smoke test", () => {
|
describe("EditingPlanner module smoke test", () => {
|
||||||
it("should load all editing-planner modules", () => {
|
it("should load all editing-planner modules", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user