From 5f2c4841143d17791472bde1a22977a423f110e6 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 10:05:02 +0800 Subject: [PATCH 1/3] refactor(timeline-panel): Phase 2 - extract 5 UI components (ClipCard/TimeRuler/AddClipPicker/TrimPreview/ContextMenu) - Extract ClipCard component (~120 lines) with drag, trim, hover, remove handlers - Extract TimeRuler component with ruler marks generation - Extract AddClipPicker component for type+duration selection - Extract TrimPreview tooltip component - Extract ContextMenu component for right-click operations - Main file: 774 -> 598 lines (-23%) --- .../components/TimelinePanel.tsx | 291 ++++-------------- .../components/timeline/AddClipPicker.tsx | 80 +++++ .../components/timeline/ClipCard.tsx | 155 ++++++++++ .../components/timeline/ContextMenu.tsx | 49 +++ .../components/timeline/TimeRuler.tsx | 27 ++ .../components/timeline/TrimPreview.tsx | 36 +++ .../test/pages/editing-planner/smoke.test.tsx | 5 + 7 files changed, 413 insertions(+), 230 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx create mode 100644 apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx create mode 100644 apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx create mode 100644 apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx create mode 100644 apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 2a2f6f87f..e88bd96a3 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,7 @@ 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 +491,7 @@ const TimelinePanel: React.FC = ({ {/* 时间标尺 */} {currentMode !== "one_take" && ( -
-
- {rulerMarks.map((t) => ( - - {t}s - - ))} -
-
+ )} {/* 水平片段轨道 */} @@ -525,115 +513,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 +555,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..55fd6f2e3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx @@ -0,0 +1,155 @@ +import React from "react" +import type { ClipData, TrimConfig } 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" -- 2.54.0 From a8aa7d00629586a97e85c1641cd01e237453b0ba Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 11:49:10 +0800 Subject: [PATCH 2/3] fix(phase2): remove unused TrimConfig import in ClipCard --- .../src/pages/editing-planner/components/timeline/ClipCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx index 55fd6f2e3..a7c402ba7 100644 --- a/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx +++ b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx @@ -1,5 +1,5 @@ import React from "react" -import type { ClipData, TrimConfig } from "../../types" +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" -- 2.54.0 From 7041fe44cae269aaea63502d0828198df5f50a36 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 12:09:27 +0800 Subject: [PATCH 3/3] fix(phase2): remove unused trackWidth variable --- apps/web/src/pages/editing-planner/components/TimelinePanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index e88bd96a3..c89dccc9c 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -430,7 +430,6 @@ const TimelinePanel: React.FC = ({ setContextMenu(null) }, [contextMenu, onClipRemove]) - const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH) return (
-- 2.54.0