完善韩泰 EOT 标注编辑器并更新标注规范与项目资料

This commit is contained in:
Xin Wang
2026-10-09 15:55:46 +08:00
parent 138b08a8e4
commit 0204f1288a
29 changed files with 1340 additions and 1271 deletions
+208 -118
View File
@@ -11,8 +11,10 @@ import {
type PointerEvent,
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
export interface TimelineTranscript {
index: number;
role: "agent" | "user";
start_ms: number;
end_ms: number;
@@ -33,11 +35,15 @@ export interface TimelineProps {
transcripts: TimelineTranscript[];
annotations: TimelineAnnotation[];
selectedId: string | null;
waveform: number[] | null;
editingTranscriptIndex: number | null;
waveform: number[][] | null;
readOnly?: boolean;
onSeek: (ms: number) => void;
onDecisionChange: (ms: number) => void;
onSelectAnnotation: (id: string) => void;
onMoveAnnotation: (id: string, ms: number) => void;
onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
onSelectTranscript: (index: number) => void;
onResizeTranscript: (index: number, startMs: number, endMs: number) => void;
onAddAnnotation: (ms: number) => void;
}
const ZOOM_LEVELS = [1, 2, 4, 8];
@@ -66,20 +72,32 @@ export default function Timeline({
transcripts,
annotations,
selectedId,
editingTranscriptIndex,
waveform,
readOnly = false,
onSeek,
onDecisionChange,
onMoveAnnotation,
onSelectAnnotation,
onSelectTranscript,
onResizeTranscript,
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const rangeId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null);
const draggingRef = useRef<
| { kind: "seek"; pointerId: number }
| { kind: "decision"; pointerId: number; id: string; start: number; next: number }
| { kind: "resize"; pointerId: number; index: number; edge: "start" | "end"; start: number; end: number; nextStart: number; nextEnd: number }
| null
>(null);
const zoomAnchorRef = useRef<number | null>(null);
const suppressAnnotationClickRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(800);
const [zoom, setZoom] = useState(1);
const [resizePreview, setResizePreview] = useState<{ index: number; start_ms: number; end_ms: number } | null>(null);
const [snappedPoint, setSnappedPoint] = useState<SnapPoint | null>(null);
const [decisionPreview, setDecisionPreview] = useState<{ id: string; ms: number } | null>(null);
const duration = Math.max(
1_000,
@@ -91,9 +109,17 @@ export default function Timeline({
const canvasWidth = Math.max(360, viewportWidth) * zoom;
const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value))));
const current = clamp(currentMs);
const decision = decisionMs === null ? null : clamp(decisionMs);
const decision = decisionPreview?.id === selectedId ? decisionPreview.ms : decisionMs === null ? null : clamp(decisionMs);
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
const activeSnap = snappedPoint && (resizePreview || decisionPreview || current === snappedPoint.ms || decision === snappedPoint.ms) ? snappedPoint : null;
const displayedTranscripts = transcripts.map(item => resizePreview?.index === item.index ? { ...item, ...resizePreview } : item);
const snapPoints = useMemo(() => [
...annotations.map(item => ({ ms: item.decision_ms, label: "EOT 判断点", annotationId: item.id })),
...transcripts.flatMap(item => [
{ ms: item.start_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段起点`, index: item.index },
{ ms: item.end_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段终点`, index: item.index },
]),
], [annotations, transcripts]);
useEffect(() => {
const viewport = viewportRef.current;
@@ -119,23 +145,23 @@ export default function Timeline({
return result;
}, [duration, canvasWidth]);
const waveformPath = useMemo(() => {
if (!waveform?.length) return "";
const waveformPaths = useMemo(() => (waveform || []).map(channel => {
if (!channel.length) return "";
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = waveform.length / barCount;
const barCount = Math.min(channel.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = channel.length / barCount;
let path = "";
for (let bar = 0; bar < barCount; bar++) {
const start = Math.floor(bar * bucketSize);
const end = Math.min(waveform.length, Math.ceil((bar + 1) * bucketSize));
const end = Math.min(channel.length, Math.ceil((bar + 1) * bucketSize));
let peak = 0;
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[index]))));
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(channel[index]))));
const x = ((bar + 0.5) / barCount) * canvasWidth;
const halfHeight = Math.max(0.75, peak * 28);
path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`;
}
return path;
}, [waveform, canvasWidth]);
}), [waveform, canvasWidth]);
function changeZoom(nextZoom: number) {
const viewport = viewportRef.current;
@@ -148,29 +174,69 @@ export default function Timeline({
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
}
function pointerTime(event: PointerEvent<HTMLElement>) {
function pointerTime(event: { clientX: number }) {
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return current;
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
}
function beginDrag(event: PointerEvent<HTMLElement>, kind: "seek" | "decision") {
if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return;
function magneticTime(event: { clientX: number; altKey: boolean }, excludeIndex?: number, excludeAnnotationId?: string) {
const points: SnapPoint[] = snapPoints.filter(point =>
!(excludeIndex !== undefined && "index" in point && point.index === excludeIndex) &&
!(excludeAnnotationId !== undefined && "annotationId" in point && point.annotationId === excludeAnnotationId));
if (excludeIndex !== undefined || excludeAnnotationId !== undefined) points.push({ ms: current, label: "播放位置" });
const result = snapTimelineTime(pointerTime(event), points, duration, canvasWidth, event.altKey);
setSnappedPoint(result.point);
return result.ms;
}
function beginDrag(event: PointerEvent<HTMLElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind, pointerId: event.pointerId };
if (kind === "seek") onSeek(pointerTime(event));
draggingRef.current = { kind: "seek", pointerId: event.pointerId };
onSeek(magneticTime(event));
}
function beginDecisionDrag(event: PointerEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || event.button !== 0) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
suppressAnnotationClickRef.current = null;
draggingRef.current = { kind: "decision", pointerId: event.pointerId, id: item.id, start: item.decision_ms, next: item.decision_ms };
setDecisionPreview({ id: item.id, ms: item.decision_ms });
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
}
function beginResize(event: PointerEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
if (readOnly || event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind: "resize", pointerId: event.pointerId, index: item.index, edge, start: item.start_ms, end: item.end_ms, nextStart: item.start_ms, nextEnd: item.end_ms };
setResizePreview({ index: item.index, start_ms: item.start_ms, end_ms: item.end_ms });
}
function moveDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
const ms = pointerTime(event);
if (drag.kind === "seek") onSeek(ms);
else onDecisionChange(ms);
if (drag.kind === "resize") {
const ms = magneticTime(event, drag.index);
const range = resizeTranscriptRange(drag.start, drag.end, drag.edge, ms, duration);
drag.nextStart = range.start_ms; drag.nextEnd = range.end_ms;
setResizePreview({ index: drag.index, ...range });
if ((drag.edge === "start" ? range.start_ms : range.end_ms) !== ms) setSnappedPoint(null);
} else if (drag.kind === "seek") onSeek(magneticTime(event));
else {
drag.next = magneticTime(event, undefined, drag.id);
setDecisionPreview({ id: drag.id, ms: drag.next });
}
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
const viewport = viewportRef.current;
if (viewport) {
@@ -181,15 +247,45 @@ export default function Timeline({
}
function endDrag(event: PointerEvent<HTMLElement>) {
if (draggingRef.current?.pointerId !== event.pointerId) return;
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
draggingRef.current = null;
if (drag.kind === "resize" && (drag.start !== drag.nextStart || drag.end !== drag.nextEnd)) onResizeTranscript(drag.index, drag.nextStart, drag.nextEnd);
if (drag.kind === "decision" && drag.start !== drag.next) {
suppressAnnotationClickRef.current = drag.id;
onMoveAnnotation(drag.id, drag.next);
}
setResizePreview(null);
setDecisionPreview(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>, kind: "seek" | "decision") {
if (kind === "decision" && readOnly) return;
const base = kind === "seek" ? current : decision;
if (base === null) return;
function cancelDrag() { if (!draggingRef.current) return; draggingRef.current = null; setResizePreview(null); setDecisionPreview(null); setSnappedPoint(null); }
function keyboardAnnotation(event: KeyboardEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const ms = event.key === "Home" ? 0 : event.key === "End" ? duration : item.decision_ms + (["ArrowLeft", "ArrowDown"].includes(event.key) ? -step : step);
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
onMoveAnnotation(item.id, clamp(ms));
}
function keyboardResize(event: KeyboardEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const base = edge === "start" ? item.start_ms : item.end_ms;
const value = event.key === "Home" ? 0 : event.key === "End" ? duration : base + (event.key === "ArrowLeft" ? -step : step);
const range = resizeTranscriptRange(item.start_ms, item.end_ms, edge, value, duration);
if (range.start_ms !== item.start_ms || range.end_ms !== item.end_ms) onResizeTranscript(item.index, range.start_ms, range.end_ms);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>) {
const base = current;
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
let next: number | null = null;
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
@@ -199,18 +295,19 @@ export default function Timeline({
if (next === null) return;
event.preventDefault();
event.stopPropagation();
if (kind === "seek") onSeek(clamp(next));
else onDecisionChange(clamp(next));
onSeek(clamp(next));
}
const trackStyle = { "--timeline-width": `${canvasWidth}px` } as CSSProperties;
const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 1}`;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<span className={styles.title}>对话时间轴</span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长</span></span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长{waveform && ` · ${channelCount === 2 ? "双声道" : `${channelCount} 声道`}`}</span></span>
</div>
<div className={styles.tools}>
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
@@ -227,7 +324,7 @@ export default function Timeline({
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
<div className={styles.waveName}>录音</div>
{Array.from({ length: channelCount }, (_, index) => <div className={styles.waveName} key={index}><span className={index % 2 ? styles.rightDot : styles.leftDot} />{waveform ? channelLabel(index) : "录音"}</div>)}
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
@@ -240,83 +337,104 @@ export default function Timeline({
tabIndex={0}
role="group"
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
onPointerDown={(event) => beginDrag(event, "seek")}
onPointerDown={(event) => beginDrag(event)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={() => { draggingRef.current = null; }}
onKeyDown={(event) => keyboardMove(event, "seek")}
onPointerCancel={cancelDrag}
onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}
>
<div className={styles.ruler} aria-hidden="true">
{ticks.map((tick) => <span key={tick.time} className={styles.tick} style={{ left: position(tick.time) }}>{tick.label}</span>)}
</div>
<div className={styles.waveTrack}>
{waveformPath ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label="录音真实振幅波形">
<path d={waveformPath} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4m4-7v10m4-13v16m4-13v10m4-7v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
<span>载入录音后显示波形</span>
</div>
)}
</div>
{Array.from({ length: channelCount }, (_, index) => (
<div key={index} className={`${styles.waveTrack} ${index % 2 ? styles.rightChannel : styles.leftChannel}`}>
{waveformPaths[index] ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label={`${channelLabel(index)}真实振幅波形`}>
<line x1="0" y1="38" x2={canvasWidth} y2="38" stroke="currentColor" opacity=".15" />
<path d={waveformPaths[index]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}><span>载入录音后显示各声道波形</span></div>
)}
</div>
))}
{(["agent", "user"] as const).map((role) => (
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}>
{transcripts.filter((item) => item.role === role).map((item, index) => {
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}
role="group"
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (event.target === event.currentTarget) onSeek(magneticTime(event)); }}
title="点击片段在右侧编辑;拖动片段左右边界调整时间">
{displayedTranscripts.filter((item) => item.role === role).map((item) => {
const start = position(item.start_ms);
const end = position(item.end_ms);
return <span
key={`${role}-${item.start_ms}-${index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock}`}
return <div
key={`${role}-${item.index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock} ${resizePreview?.index === item.index ? styles.resizingBlock : ""} ${editingTranscriptIndex === item.index ? styles.editingBlock : ""}`}
style={{ left: start, width: Math.max(3, end - start) }}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
><span>{item.transcript}</span></span>;
>
<button type="button" className={styles.blockBody}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
aria-label={`${readOnly ? "查看" : "编辑"}${role === "agent" ? "机器人" : "用户"}片段 ${timeLabel(item.start_ms, true)} ${item.transcript}`}
onPointerDown={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); onSelectTranscript(item.index); }}
><span>{item.transcript}</span></button>
{!readOnly && (["start", "end"] as const).map(edge => <button type="button" key={edge}
className={`${styles.resizeHandle} ${edge === "start" ? styles.startHandle : styles.endHandle}`}
aria-label={`调整${role === "agent" ? "机器人" : "用户"}片段${edge === "start" ? "起点" : "终点"} ${timeLabel(item.start_ms, true)}`}
title="拖动调整边界;方向键微调,按住 Alt 暂停吸附"
onPointerDown={event => beginResize(event, item, edge)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={event => keyboardResize(event, item, edge)} onClick={event => event.stopPropagation()}
/>)}
{resizePreview?.index === item.index && <span className={styles.resizeReadout}>{timeLabel(item.start_ms, true)} — {timeLabel(item.end_ms, true)}</span>}
</div>;
})}
</div>
))}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注">
{annotations.filter((item) => item.id !== selectedId).map((item, index) => (
<button
type="button"
key={item.id}
className={`${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(item.decision_ms) }}
title={`标注 ${index + 1} · ${timeLabel(item.decision_ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}`}
aria-label={`选择 ${timeLabel(item.decision_ms, true)} 的标注,${annotationLabel(item.can_reply_now, item.reason)}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onSelectAnnotation(item.id); }}
onKeyDown={(event) => event.stopPropagation()}
><span className={styles.annotationStem} aria-hidden="true" /><span>{item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>
))}
{decision !== null && <span className={styles.selectedLabel} style={{ left: position(decision) }}>
{selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"}
</span>}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注"
role="group" tabIndex={readOnly ? -1 : 0}
title={readOnly ? "点击判断点查看" : "点击空白处新增判断点;Enter 在播放位置新增"}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (!readOnly) onAddAnnotation(magneticTime(event)); }}
onKeyDown={event => { event.stopPropagation(); if (!readOnly && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onAddAnnotation(current); } }}>
{annotations.map((item, index) => {
const ms = decisionPreview?.id === item.id ? decisionPreview.ms : item.decision_ms;
const isSelected = item.id === selectedId;
return <button type="button" key={item.id}
className={isSelected ? styles.selectedLabel : `${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(ms), cursor: readOnly ? "pointer" : undefined }}
title={`标注 ${index + 1} · ${timeLabel(ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : " · 拖动调整时间,Alt 暂停吸附"}`}
aria-label={`${isSelected ? "当前" : "选择"}标注 ${timeLabel(ms, true)},${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : ",可拖动"}`}
aria-describedby={helpId}
onPointerDown={event => beginDecisionDrag(event, item)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onClick={event => {
event.stopPropagation();
if (suppressAnnotationClickRef.current === item.id) { suppressAnnotationClickRef.current = null; return; }
onSelectAnnotation(item.id);
}}
onKeyDown={event => keyboardAnnotation(event, item)}
><span className={styles.annotationStem} aria-hidden="true" /><span>{isSelected ? annotationLabel(item.can_reply_now, item.reason) : item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>;
})}
</div>
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></div>
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
title="拖动播放头,自动吸附片段边界和判断点;Alt 暂停吸附"
onPointerDown={event => beginDrag(event)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}><span /></button>
</div>
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
<div className={styles.decisionLine} aria-hidden="true" />
<button
type="button"
className={styles.decisionHandle}
title={readOnly ? "当前判断时间(查看模式)" : "拖动调整判断时间;方向键微调 100 ms,Shift 微调 1 秒"}
aria-label={`当前判断点 ${timeLabel(decision, true)}${readOnly ? ",查看模式" : ",拖动或使用方向键调整"}`}
aria-describedby={helpId}
aria-disabled={readOnly}
style={readOnly ? { cursor: "default" } : undefined}
onPointerDown={(event) => { event.stopPropagation(); if (!readOnly) beginDrag(event, "decision"); }}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={(event) => { event.stopPropagation(); draggingRef.current = null; }}
onKeyDown={(event) => { event.stopPropagation(); keyboardMove(event, "decision"); }}
><span aria-hidden="true" /></button>
</div>}
</div>
</div>
@@ -324,35 +442,7 @@ export default function Timeline({
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}</span>
</div>
<div className={styles.decisionControls}>
<label htmlFor={rangeId}><span className={styles.decisionDot} />判断时间</label>
<input
id={rangeId}
type="range"
min={0}
max={duration}
step={1}
value={decision ?? 0}
disabled={decision === null || readOnly}
aria-valuetext={decision === null ? "未选择判断点" : timeLabel(decision, true)}
onChange={(event) => onDecisionChange(clamp(Number(event.currentTarget.value)))}
/>
<label className={styles.numberInput}>
<input
type="number"
aria-label="判断时间,单位毫秒"
min={0}
max={duration}
step={1}
value={decision ?? ""}
placeholder="—"
disabled={decision === null || readOnly}
onChange={(event) => { if (event.currentTarget.value !== "") onDecisionChange(clamp(Number(event.currentTarget.value))); }}
/>
<span>ms</span>
</label>
<span id={helpId} className={styles.help}>{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}</span>
</div>
</section>
);