diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 2a2f6f87f..c89dccc9c 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -10,25 +10,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, + TRACK_GAP, + ADD_PICKER_WIDTH, } from "../constants/timeline" -import { formatTime, formatTrimTime, generateRulerMarks } from "../utils/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[] @@ -431,10 +430,6 @@ const TimelinePanel: React.FC = ({ setContextMenu(null) }, [contextMenu, onClipRemove]) - /* ── 时间标尺 ── */ - const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH) - const step = getRulerStep(totalDuration) - const rulerMarks = generateRulerMarks(totalDuration, step) return (
@@ -495,15 +490,7 @@ const TimelinePanel: React.FC = ({ {/* 时间标尺 */} {currentMode !== "one_take" && ( -
-
- {rulerMarks.map((t) => ( - - {t}s - - ))} -
-
+ )} {/* 水平片段轨道 */} @@ -525,115 +512,30 @@ const TimelinePanel: React.FC = ({
点击右侧 + 添加片段
) : ( - clips.map((clip, idx) => { - /* 转场指示器 */ - const trans = clip.transition - const showTransition = idx > 0 && trans && trans.type !== "none" - const transOpt = showTransition - ? TRANSITION_OPTIONS.find((o) => o.value === trans!.type) - : undefined - - /* 速度徽章 */ - const speed = clip.speed - const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01 - - /* 裁剪状态 */ - const hasTrim = !!clip.trim_config - const isHovered = hoveredClipId === clip.id - - return ( - - {/* 转场指示器 */} - {showTransition && transOpt && ( -
- {transOpt.icon} - {trans!.duration.toFixed(1)}s -
- )} - -
handleDragStart(e, idx)} - onDragOver={(e) => handleDragOver(e, idx)} - onDragEnd={handleDragEnd} - onDrop={(e) => handleDrop(e, idx)} - onClick={() => onClipSelect(clip.id)} - onContextMenu={(e) => handleContextMenu(e, clip.id)} - onMouseEnter={() => setHoveredClipId(clip.id)} - onMouseLeave={() => setHoveredClipId(null)} - > - {/* 左裁剪手柄 */} - {isHovered && onClipTrim && ( -
handleTrimHandleMouseDown(e, clip.id, "left")} - title="拖动调整入点" - > -
-
- )} - - {/* 类型图标 */} -
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
- - {/* 片段信息 */} -
- - {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} - - - {clip.duration}s - {hasTrim && ( - - ✂ - - )} - -
- - {/* 速度徽章 */} - {showSpeed && {speed!.rate.toFixed(1)}x} - - {/* 裁剪徽章 */} - {hasTrim && ( - - ✂ - - )} - - {/* 右裁剪手柄 */} - {isHovered && onClipTrim && ( -
handleTrimHandleMouseDown(e, clip.id, "right")} - title="拖动调整出点" - > -
-
- )} - - {/* 删除按钮 */} - -
- - ) - }) + clips.map((clip, idx) => ( + setHoveredClipId(clip.id)} + onMouseLeave={() => setHoveredClipId(null)} + onTrimHandleMouseDown={handleTrimHandleMouseDown} + onRemove={onClipRemove} + /> + )) )} {/* ── 轨道末尾 "+" 添加卡片 ── */} @@ -652,114 +554,42 @@ const TimelinePanel: React.FC = ({ {/* 裁剪预览 tooltip */} {trimPreview && ( -
-
- 入点 - {formatTrimTime(trimPreview.startTime)} -
-
- 出点 - {formatTrimTime(trimPreview.endTime)} -
-
- 时长 - {formatTrimTime(trimPreview.duration)} -
-
+ )} {/* 右键菜单 */} {contextMenu && ( -
-
- ✂️ - 分割片段 -
- {clips.find((c) => c.id === contextMenu.clipId)?.trim_config && ( -
- ↩️ - 恢复原始长度 -
- )} -
-
- 🗑️ - 删除片段 -
-
+ c.id === contextMenu.clipId)?.trim_config} + onSplit={handleContextSplit} + onResetTrim={handleContextResetTrim} + onDelete={handleContextDelete} + /> )} {/* 类型+时长选择面板 */} {showAddPicker && ( -
-
添加片段
- - {/* 类型选择 */} -
- 类型: - {availableTypes.map((t) => ( - - ))} -
- - {/* 时长输入 */} -
- 时长: - - setAddDuration( - Math.max( - MIN_ADD_DURATION, - Math.min(MAX_ADD_DURATION, Number(e.target.value) || MIN_ADD_DURATION), - ), - ) - } - /> - -
- - {/* 确认按钮 */} - -
+ )}
) diff --git a/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx new file mode 100644 index 000000000..ecab58aa5 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx @@ -0,0 +1,80 @@ +import React from "react" +import type { ClipType } from "../../types" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline" + +interface AddClipPickerProps { + pickerRef: React.RefObject + position: { top: number; right: number } + availableTypes: ClipType[] + addType: ClipType + addDuration: number + onTypeChange: (type: ClipType) => void + onDurationChange: (duration: number) => void + onConfirm: () => void + minDuration?: number + maxDuration?: number +} + +export const AddClipPicker: React.FC = ({ + pickerRef, + position, + availableTypes, + addType, + addDuration, + onTypeChange, + onDurationChange, + onConfirm, + minDuration = 1, + maxDuration = 120, +}) => { + return ( +
+
添加片段
+ + {/* 类型选择 */} +
+ 类型: + {availableTypes.map((t) => ( + + ))} +
+ + {/* 时长输入 */} +
+ 时长: + + onDurationChange( + Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)), + ) + } + /> + +
+ + {/* 确认按钮 */} + +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx new file mode 100644 index 000000000..a7c402ba7 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx @@ -0,0 +1,155 @@ +import React from "react" +import type { ClipData } from "../../types" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline" + +interface ClipCardProps { + clip: ClipData + idx: number + isSelected: boolean + isDragging: boolean + isDragOver: boolean + isHovered: boolean + pps: number + trimDragActive: boolean + showTrimHandles: boolean + onDragStart: (e: React.DragEvent, idx: number) => void + onDragOver: (e: React.DragEvent, idx: number) => void + onDragEnd: () => void + onDrop: (e: React.DragEvent, idx: number) => void + onSelect: (clipId: string) => void + onContextMenu: (e: React.MouseEvent, clipId: string) => void + onMouseEnter: () => void + onMouseLeave: () => void + onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void + onRemove: (clipId: string) => void +} + +export const ClipCard: React.FC = ({ + clip, + idx, + isSelected, + isDragging, + isDragOver, + isHovered, + pps, + trimDragActive, + showTrimHandles, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + onSelect, + onContextMenu, + onMouseEnter, + onMouseLeave, + onTrimHandleMouseDown, + onRemove, +}) => { + /* 转场指示器 */ + const trans = clip.transition + const showTransition = idx > 0 && trans && trans.type !== "none" + const transOpt = showTransition + ? TRANSITION_OPTIONS.find((o) => o.value === trans!.type) + : undefined + + /* 速度徽章 */ + const speed = clip.speed + const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01 + + /* 裁剪状态 */ + const hasTrim = !!clip.trim_config + + return ( + + {/* 转场指示器 */} + {showTransition && transOpt && ( +
+ {transOpt.icon} + {trans!.duration.toFixed(1)}s +
+ )} + +
onDragStart(e, idx)} + onDragOver={(e) => onDragOver(e, idx)} + onDragEnd={onDragEnd} + onDrop={(e) => onDrop(e, idx)} + onClick={() => onSelect(clip.id)} + onContextMenu={(e) => onContextMenu(e, clip.id)} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {/* 左裁剪手柄 */} + {isHovered && showTrimHandles && ( +
onTrimHandleMouseDown(e, clip.id, "left")} + title="拖动调整入点" + > +
+
+ )} + + {/* 类型图标 */} +
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
+ + {/* 片段信息 */} +
+ + {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} + + + {clip.duration}s + {hasTrim && ( + + ✂ + + )} + +
+ + {/* 速度徽章 */} + {showSpeed && {speed!.rate.toFixed(1)}x} + + {/* 裁剪徽章 */} + {hasTrim && ( + + ✂ + + )} + + {/* 右裁剪手柄 */} + {isHovered && showTrimHandles && ( +
onTrimHandleMouseDown(e, clip.id, "right")} + title="拖动调整出点" + > +
+
+ )} + + {/* 删除按钮 */} + +
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx b/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx new file mode 100644 index 000000000..8042aa563 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx @@ -0,0 +1,49 @@ +import React from "react" + +interface ContextMenuProps { + x: number + y: number + menuRef: React.RefObject + hasTrim: boolean + onSplit: () => void + onResetTrim: () => void + onDelete: () => void +} + +export const ContextMenu: React.FC = ({ + x, + y, + menuRef, + hasTrim, + onSplit, + onResetTrim, + onDelete, +}) => { + return ( +
+
+ ✂️ + 分割片段 +
+ {hasTrim && ( +
+ ↩️ + 恢复原始长度 +
+ )} +
+
+ 🗑️ + 删除片段 +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx b/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx new file mode 100644 index 000000000..5d1588360 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline" +import { generateRulerMarks } from "../../utils/timeline" + +interface TimeRulerProps { + totalDuration: number + pps: number + onClick: (e: React.MouseEvent) => void +} + +export const TimeRuler: React.FC = ({ totalDuration, pps, onClick }) => { + const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH) + const step = getRulerStep(totalDuration) + const marks = generateRulerMarks(totalDuration, step) + + return ( +
+
+ {marks.map((t) => ( + + {t}s + + ))} +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx b/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx new file mode 100644 index 000000000..84399fb3c --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx @@ -0,0 +1,36 @@ +import React from "react" +import { formatTrimTime } from "../../utils/timeline" + +interface TrimPreviewProps { + startTime: number + endTime: number + duration: number + x: number + y: number +} + +export const TrimPreview: React.FC = ({ startTime, endTime, duration, x, y }) => { + return ( +
+
+ 入点 + {formatTrimTime(startTime)} +
+
+ 出点 + {formatTrimTime(endTime)} +
+
+ 时长 + {formatTrimTime(duration)} +
+
+ ) +} diff --git a/apps/web/src/test/pages/editing-planner/smoke.test.tsx b/apps/web/src/test/pages/editing-planner/smoke.test.tsx index 1ef59fc39..ae48d3995 100644 --- a/apps/web/src/test/pages/editing-planner/smoke.test.tsx +++ b/apps/web/src/test/pages/editing-planner/smoke.test.tsx @@ -38,6 +38,11 @@ import "@/pages/editing-planner/components/StatusBar" import "@/pages/editing-planner/components/StickerPanel" import "@/pages/editing-planner/components/SubtitleStylePanel" import "@/pages/editing-planner/components/TimelinePanel" +import "@/pages/editing-planner/components/timeline/ClipCard" +import "@/pages/editing-planner/components/timeline/TimeRuler" +import "@/pages/editing-planner/components/timeline/AddClipPicker" +import "@/pages/editing-planner/components/timeline/TrimPreview" +import "@/pages/editing-planner/components/timeline/ContextMenu" import "@/pages/editing-planner/components/TopBar" import "@/pages/editing-planner/components/TransitionSelector" import "@/pages/editing-planner/components/TtsPanel"