From 60dc6dbb3c32349db7723c454f828ca1fa980d3e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 15:41:11 +0800 Subject: [PATCH 1/3] refactor(timeline-panel): Phase 3 - extract 3 business hooks (useTimelineMenus/useTrimDrag/useClipDrag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract context menu + add clip picker logic into useTimelineMenus hook - Extract trim drag logic into useTrimDrag hook - Extract clip reorder drag logic into useClipDrag hook - TimelinePanel: 597 → 317 lines (-47%) - Add new hooks to smoke test --- .../components/TimelinePanel.tsx | 372 +++--------------- .../editing-planner/hooks/useClipDrag.ts | 64 +++ .../editing-planner/hooks/useTimelineMenus.ts | 198 ++++++++++ .../editing-planner/hooks/useTrimDrag.ts | 121 ++++++ .../test/pages/editing-planner/smoke.test.tsx | 3 + 5 files changed, 432 insertions(+), 326 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/hooks/useClipDrag.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useTimelineMenus.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useTrimDrag.ts diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 5b6f037a4..f9e1f35ec 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -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 { - 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 { DEFAULT_PIXELS_PER_SECOND } from "../constants/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 { TimeRuler } from "./timeline/TimeRuler" import { AddClipPicker } from "./timeline/AddClipPicker" @@ -55,25 +47,6 @@ interface TimelinePanelProps { 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, @@ -91,143 +64,51 @@ const TimelinePanel: React.FC = ({ 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 pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND + const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 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 { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim) - /* ── 右键菜单 ── */ - const [contextMenu, setContextMenu] = useState(null) - const contextMenuRef = useRef(null) + /* ── 片段拖拽排序 ── */ + const { + dragIdx, + dragOverIdx, + handleDragStart, + handleDragOver, + handleDragEnd, + handleDrop, + handleEmptyDragOver, + } = useClipDrag(onClipReorder, !!trimDrag) - /* ── 悬停的片段 ID(显示裁剪手柄) ── */ - const [hoveredClipId, setHoveredClipId] = useState(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 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 @@ -271,165 +152,6 @@ const TimelinePanel: React.FC = ({ 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 (
{/* 时间线头部 */} @@ -443,7 +165,7 @@ const TimelinePanel: React.FC = ({