7ec8e4355e
- 面板默认从+按钮上方弹出,上方不够时翻转到下方 - 用useLayoutEffect+实际offsetHeight替代硬编码PICKER_H - fixed定位脱离父容器overflow裁剪 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
381 lines
12 KiB
TypeScript
Executable File
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;
|