Files
xiaoxia-saas/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx
T
灵应 7ec8e4355e fix(editing-planner): 修复添加片段面板被裁剪 — 默认向上弹出+useLayoutEffect精确校正
- 面板默认从+按钮上方弹出,上方不够时翻转到下方
- 用useLayoutEffect+实际offsetHeight替代硬编码PICKER_H
- fixed定位脱离父容器overflow裁剪

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:33 +08:00

381 lines
12 KiB
TypeScript
Executable File

/**
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
* "+" 卡片 → 类型+时长选择器
*/
import React, {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
} from "react";
import type { ClipData, ClipType } from "../types";
interface TimelinePanelProps {
clips: ClipData[];
selectedClipId: string | null;
currentMode: string;
onClipSelect: (clipId: string) => void;
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAddClip: (type: ClipType, duration: number) => void;
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
};
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
currentMode,
onClipSelect,
onClipReorder,
onClipRemove,
onAddClip,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const dragRef = useRef<number | null>(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,
});
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
updatePickerPosition();
}
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
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 - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
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 - PICKER_W - 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]);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
setShowAddPicker(false);
};
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
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 totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const rulerMarks: number[] = [];
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
for (let t = 0; t <= totalDuration + step; t += step) {
rulerMarks.push(t);
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
<div className="ep-timeline-header">
<div className="ep-timeline-title">
<span>🎬 时间线</span>
<span className="ep-timeline-duration">
总时长: {formatTime(totalDuration)}
</span>
</div>
<div className="ep-timeline-actions">
<button
className="ep-timeline-action-btn"
onClick={() => {
if (clips.length > 1) {
const last = clips[clips.length - 1];
onClipRemove(last.id);
}
}}
title="删除最后一个片段"
>
↩
</button>
<button className="ep-timeline-action-btn" title="重做">
↪
</button>
</div>
</div>
{/* 一镜到底提示 */}
{currentMode === "one_take" && (
<div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>
)}
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
{t}s
</span>
))}
</div>
</div>
)}
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
✕
</button>
</div>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
className="ep-track-add-card"
onClick={handleTogglePicker}
title="添加片段"
>
+
</div>
</div>
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title">添加片段</div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">类型:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">时长:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
)
}
/>
<span className="ep-add-clip-duration-unit">秒</span>
</div>
{/* 确认按钮 */}
<button
className="ep-add-clip-confirm-btn"
onClick={handleConfirmAdd}
>
添加
</button>
</div>
)}
</div>
);
};
export default TimelinePanel;