diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index f9e1f35ec..8085576c2 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -1,22 +1,32 @@ /** * 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单 - * 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序 - * "+" 卡片 → 类型+时长选择器 + * 时间标尺 + 片段轨道 + HTML5拖拽排序 * - * 裁剪交互: - * - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点 - * - 拖动时实时显示裁剪预览(入点/出点/时长) - * - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除 + * 子组件(timeline/): + * - TimeRuler - 时间标尺 + * - ClipCard - 片段卡片 + * - ClipTrack - 片段轨道(播放头+片段列表+添加卡片) + * - TimelineHeader - 时间线头部(标题+缩放+操作按钮) + * - AddClipPicker - 添加片段选择器 + * - TrimPreview - 裁剪预览 tooltip + * - ContextMenu - 右键菜单 + * + * Hooks: + * - useClipDrag - 片段拖拽排序 + * - useTrimDrag - 裁剪拖拽 + * - useTimelineMenus - 菜单 & 面板 + * - usePlayheadDrag - 播放头拖拽 */ -import React, { useState, useRef, useCallback, useEffect } from "react" +import React from "react" import type { ClipData, ClipType, TrimConfig } from "../types" 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 { usePlayheadDrag } from "./timeline/hooks/usePlayheadDrag" import { TimeRuler } from "./timeline/TimeRuler" +import { ClipTrack } from "./timeline/ClipTrack" +import { TimelineHeader } from "./timeline/TimelineHeader" import { AddClipPicker } from "./timeline/AddClipPicker" import { TrimPreview } from "./timeline/TrimPreview" import { ContextMenu } from "./timeline/ContextMenu" @@ -105,106 +115,32 @@ const TimelinePanel: React.FC = ({ setHoveredClipId, } = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove) - /* ── 播放头拖拽状态 ── */ - const [playheadDragging, setPlayheadDragging] = useState(false) - const trackRef = useRef(null) + /* ── 播放头拖拽 ── */ + const { trackRef, handlePlayheadMouseDown, handleRulerClick } = usePlayheadDrag({ + currentTime, + pps, + totalDuration, + onSeek, + }) - /* ── 播放头拖拽全局 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 handleUndoClip = () => { + if (clips.length > 1) { + const last = clips[clips.length - 1] + onClipRemove(last.id) } - - 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) - }, []) + } return (
{/* 时间线头部 */} -
-
- 🎬 时间线 - 总时长: {formatTime(totalDuration)} -
-
- {/* 缩放控件 */} -
- - onZoomChange?.(Number(e.target.value))} - /> - - {pps}px/s -
- - -
-
+ {/* 一镜到底提示 */} {currentMode === "one_take" &&
🎥 一镜到底模式无片段
} @@ -216,61 +152,33 @@ const TimelinePanel: React.FC = ({ {/* 水平片段轨道 */} {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} - /> - )) - )} - - {/* ── 轨道末尾 "+" 添加卡片 ── */} -
-
- + -
-
-
+ setHoveredClipId(null)} + onTrimHandleMouseDown={handleTrimHandleMouseDown} + onClipRemove={onClipRemove} + onTogglePicker={handleTogglePicker} + /> )} {/* 裁剪预览 tooltip */} diff --git a/apps/web/src/pages/editing-planner/components/timeline/ClipTrack.tsx b/apps/web/src/pages/editing-planner/components/timeline/ClipTrack.tsx new file mode 100644 index 000000000..37ed89dd5 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ClipTrack.tsx @@ -0,0 +1,146 @@ +import React from "react" +import type { ClipData } from "../../types" +import { ClipCard } from "./ClipCard" + +interface ClipTrackProps { + /** 片段列表 */ + clips: ClipData[] + /** 选中的片段ID */ + selectedClipId: string | null + /** 缩放:每秒像素数 */ + pps: number + /** 当前播放时间(秒) */ + currentTime: number + /** 总时长(秒) */ + totalDuration: number + /** 正在拖拽的片段索引 */ + dragIdx: number | null + /** 拖拽悬停的片段索引 */ + dragOverIdx: number | null + /** 悬停的片段ID */ + hoveredClipId: string | null + /** 是否正在裁剪拖拽 */ + trimDragActive: boolean + /** 是否显示裁剪手柄 */ + showTrimHandles: boolean + /** 轨道ref */ + trackRef: React.RefObject + /** 添加卡片ref */ + addCardRef: React.RefObject + /** 播放头拖拽开始 */ + onPlayheadMouseDown: (e: React.MouseEvent) => void + /** 片段选中 */ + onClipSelect: (clipId: string) => void + /** 拖拽开始 */ + onDragStart: (e: React.DragEvent, idx: number) => void + /** 拖拽悬停 */ + onDragOver: (e: React.DragEvent, idx: number) => void + /** 拖拽结束 */ + onDragEnd: () => void + /** 放置 */ + onDrop: (e: React.DragEvent, idx: number) => void + /** 空区域拖拽悬停 */ + onEmptyDragOver: (e: React.DragEvent) => void + /** 右键菜单 */ + onContextMenu: (e: React.MouseEvent, clipId: string) => void + /** 片段悬停进入 */ + onClipMouseEnter: (clipId: string) => void + /** 片段悬停离开 */ + onClipMouseLeave: () => void + /** 裁剪手柄按下 */ + onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void + /** 删除片段 */ + onClipRemove: (clipId: string) => void + /** 点击添加卡片 */ + onTogglePicker: () => void +} + +/** + * 片段轨道组件 + * 包含播放头、片段列表、空状态和添加卡片 + */ +export const ClipTrack: React.FC = ({ + clips, + selectedClipId, + pps, + currentTime, + totalDuration, + dragIdx, + dragOverIdx, + hoveredClipId, + trimDragActive, + showTrimHandles, + trackRef, + addCardRef, + onPlayheadMouseDown, + onClipSelect, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + onEmptyDragOver, + onContextMenu, + onClipMouseEnter, + onClipMouseLeave, + onTrimHandleMouseDown, + onClipRemove, + onTogglePicker, +}) => { + return ( +
+ {/* 播放头 */} + {totalDuration > 0 && ( +
+
+
+ )} + {clips.length === 0 ? ( +
+
🎬
+
点击右侧 + 添加片段
+
+ ) : ( + clips.map((clip, idx) => ( + onClipMouseEnter(clip.id)} + onMouseLeave={onClipMouseLeave} + onTrimHandleMouseDown={onTrimHandleMouseDown} + onRemove={onClipRemove} + /> + )) + )} + + {/* ── 轨道末尾 "+" 添加卡片 ── */} +
+
+ + +
+
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/TimelineHeader.tsx b/apps/web/src/pages/editing-planner/components/timeline/TimelineHeader.tsx new file mode 100644 index 000000000..bb07182e7 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/TimelineHeader.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { formatTime } from "../../utils/timeline" + +interface TimelineHeaderProps { + /** 总时长(秒) */ + totalDuration: number + /** 缩放:每秒像素数 */ + pps: number + /** 缩放变更回调 */ + onZoomChange?: (pps: number) => void + /** 撤销最后一个片段 */ + onUndoClip?: () => void + /** 片段数量 */ + clipsCount: number +} + +/** + * 时间线头部组件 + * 包含标题、总时长、缩放控件和操作按钮 + */ +export const TimelineHeader: React.FC = ({ + totalDuration, + pps, + onZoomChange, + onUndoClip, + clipsCount, +}) => { + return ( +
+
+ 🎬 时间线 + 总时长: {formatTime(totalDuration)} +
+
+ {/* 缩放控件 */} +
+ + onZoomChange?.(Number(e.target.value))} + /> + + {pps}px/s +
+ + +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.ts b/apps/web/src/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.ts new file mode 100644 index 000000000..fa189d090 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.ts @@ -0,0 +1,71 @@ +import { useState, useCallback, useEffect, useRef } from "react" + +interface UsePlayheadDragOptions { + /** 当前播放时间(秒) */ + currentTime: number + /** 缩放:每秒像素数 */ + pps: number + /** 总时长(秒) */ + totalDuration: number + /** 播放头跳转回调 */ + onSeek?: (time: number) => void +} + +/** + * 播放头拖拽 Hook + * 封装播放头的 mousedown/mousemove/mouseup 拖拽逻辑 + */ +export const usePlayheadDrag = ({ pps, totalDuration, onSeek }: UsePlayheadDragOptions) => { + const [playheadDragging, setPlayheadDragging] = useState(false) + const trackRef = useRef(null) + + /* ── 播放头拖拽全局 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 handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setPlayheadDragging(true) + }, []) + + /* ── 标尺点击跳转播放头 ── */ + 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], + ) + + return { + trackRef, + playheadDragging, + handlePlayheadMouseDown, + handleRulerClick, + } +} diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/AddClipPicker.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/AddClipPicker.test.tsx new file mode 100644 index 000000000..61522e00f --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/AddClipPicker.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for AddClipPicker + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/AddClipPicker" + +describe("AddClipPicker smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/ClipCard.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/ClipCard.test.tsx new file mode 100644 index 000000000..1cd2ba1ae --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/ClipCard.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for ClipCard + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/ClipCard" + +describe("ClipCard smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/ClipTrack.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/ClipTrack.test.tsx new file mode 100644 index 000000000..318df895b --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/ClipTrack.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for ClipTrack + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/ClipTrack" + +describe("ClipTrack smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/ContextMenu.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/ContextMenu.test.tsx new file mode 100644 index 000000000..5dae82d0c --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/ContextMenu.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for ContextMenu + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/ContextMenu" + +describe("ContextMenu smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/TimeRuler.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/TimeRuler.test.tsx new file mode 100644 index 000000000..37c236084 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/TimeRuler.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for TimeRuler + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/TimeRuler" + +describe("TimeRuler smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/TimelineHeader.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/TimelineHeader.test.tsx new file mode 100644 index 000000000..87d896d73 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/TimelineHeader.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for TimelineHeader + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/TimelineHeader" + +describe("TimelineHeader smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/TrimPreview.test.tsx b/apps/web/src/test/pages/editing-planner/components/timeline/TrimPreview.test.tsx new file mode 100644 index 000000000..27938fca8 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/TrimPreview.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for TrimPreview + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/TrimPreview" + +describe("TrimPreview smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.test.ts b/apps/web/src/test/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.test.ts new file mode 100644 index 000000000..bb0937aef --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/timeline/hooks/usePlayheadDrag.test.ts @@ -0,0 +1,11 @@ +/** + * Smoke test for usePlayheadDrag + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/timeline/hooks/usePlayheadDrag" + +describe("usePlayheadDrag smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +})