fix: 优化空格播放和截图角色标签对不上

This commit is contained in:
2026-09-17 19:39:24 +08:00
parent 8d67f17a84
commit e66e3374bf
19 changed files with 633 additions and 58 deletions

View File

@@ -70,6 +70,11 @@ type TimelineTimeDragOwner =
| { kind: "trajectory"; objectId: string; pointId: string }
| { kind: "camera"; cameraId: string };
/** 时间轴片段(人物轨迹条 / 运镜片段条)的稳定标识 */
function getTimelineClipKey(kind: "trajectory" | "camera", id: string) {
return `${kind}:${id}`;
}
function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
@@ -335,6 +340,8 @@ export function TimelinePanel() {
const editableDuration = project.timeline.duration ?? duration;
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
/** 选中的片段(轨迹条 / 运镜条):点条本体或条上的关键帧都会选中它 */
const [selectedClip, setSelectedClip] = useState<string | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃)
@@ -758,6 +765,29 @@ export function TimelinePanel() {
};
}
/**
* 点轨道空白处 = 取消片段选中。
* 片段 / 关键帧自己的手势里会 stopPropagation,正常情况冒泡不到这里;
* 但手势可能因为尺寸未就绪等原因提前返回(那时还没走到 stopPropagation),
* 所以这里再按事件来源兜一层,避免把刚选中的片段立刻清掉。
*/
function handleTimelineGridPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (event.target instanceof Element && event.target.closest(".timeline-trajectory-bar, .timeline-keyframe")) {
return;
}
setSelectedClip(null);
}
/**
* 片段是否处于选中态。只有一个来源:`selectedClip` ——
* 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中,
* 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。
*/
function isClipSelected(kind: "trajectory" | "camera", id: string) {
return selectedClip === getTimelineClipKey(kind, id);
}
/** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
@@ -837,6 +867,9 @@ export function TimelinePanel() {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
// 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中
setSelectedClip(getTimelineClipKey(owner.kind, owner.kind === "trajectory" ? owner.objectId : owner.cameraId));
const lane = event.currentTarget.closest(".timeline-track-lane");
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
if (laneWidth <= 0 || duration <= 0) return;
@@ -1077,6 +1110,18 @@ export function TimelinePanel() {
action();
}
// 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。
function handlePanelKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== " " && event.key !== "Spacebar") return;
const target = event.target as HTMLElement | null;
if (target && target.closest("button, input, textarea, select, [contenteditable='true']")) {
return;
}
event.preventDefault();
togglePlaying();
}
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = selectionIsManualCamera
? canAddCameraKeyframe
@@ -1091,8 +1136,11 @@ export function TimelinePanel() {
className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`}
aria-label="时间轴"
ref={panelRef}
onKeyDown={handlePanelKeyDown}
>
<div className="timeline-toolbar">
{/* tabIndex 让工具栏区可聚焦:点工具栏空白处后焦点落进面板,
这样「焦点在 timeline-toolbar 时空格切换播放/暂停」才生效(否则焦点在面板外,空格够不到面板级键盘处理) */}
<div className="timeline-toolbar" tabIndex={0}>
<div className="timeline-toolbar-group">
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
@@ -1335,7 +1383,10 @@ export function TimelinePanel() {
</div>
</div>
<div className="timeline-grid-body">
{/* 点轨道空白处(不含片段与关键帧)=取消片段选中 */}
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
{timeline.tracks.map((track) => {
const obj = project.objects.find((o) => o.id === track.objectId);
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
@@ -1442,7 +1493,9 @@ export function TimelinePanel() {
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
{cameraTimes.length >= 2 && duration > 0 ? (
<div
className="timeline-trajectory-bar timeline-clip-bar-camera"
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
@@ -1595,7 +1648,9 @@ export function TimelinePanel() {
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
<div
className="timeline-trajectory-bar"
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime