Polish editor timeline interactions

This commit is contained in:
iye
2026-05-27 17:59:55 +08:00
parent 134778dde8
commit 5edfa05369
8 changed files with 511 additions and 126 deletions
+382 -72
View File
@@ -1267,14 +1267,19 @@
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--background-lighter); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--heat); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; align-content: start; align-items: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base); }
.video-card:hover { border-color: var(--heat-40); }
.video-thumb { aspect-ratio: 9/16; max-height: 320px; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(196px, 216px)); gap: 14px; align-content: start; align-items: start; justify-content: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.video-card:hover { border-color: var(--heat-40); background: var(--background-lighter); }
.video-thumb { width: 100%; aspect-ratio: 9/16; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; overflow: hidden; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 10px 12px; }
.video-card .body { padding: 12px 12px 14px; flex: 1 1 auto; min-height: 118px; display: flex; flex-direction: column; }
.video-card-head { display: flex; align-items: flex-start; gap: 8px; }
.video-card-title { min-width: 0; flex: 1 1 auto; font-size: 13px; line-height: 1.4; font-weight: 600; color: var(--accent-black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.video-card-head .pill { flex: 0 0 auto; }
.video-meta { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; }
.video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; }
/* 视频详情 modal 大视频 + 历史版本 */
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
@@ -1334,7 +1339,7 @@
.input-mini { width: 90px; padding: 0 10px; height: 28px; font-size: 12px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--black-alpha-12); }
/* ── 时间轴 · 剪映风格(Restraint 浅色规范) ── */
.timeline { grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
.timeline { position: relative; grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
/* 工具栏 */
.tl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-faint); }
@@ -1383,6 +1388,26 @@
pointer-events: none; opacity: .55;
border-radius: inherit;
}
.tl-track .lane.is-snapping .clip,
.tl-track .lane.is-reordering .clip:not(.dragging) { transition: left .16s ease, width .16s ease; }
.tl-align-guide {
position: absolute;
width: 1.5px;
background: var(--accent-forest);
opacity: 0;
pointer-events: none;
z-index: 9;
}
.tl-align-guide.show { opacity: 1; }
.tl-insert-ghost {
position: absolute; top: 3px; bottom: 3px;
border: 1px dashed var(--heat);
background: var(--heat-12);
border-radius: 4px;
pointer-events: none;
box-sizing: border-box;
z-index: 2;
}
/* 片段公共 · 绝对定位 · left/width 由 data 驱动 */
.clip {
@@ -1391,7 +1416,7 @@
padding: 0 8px; font-size: 11px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer; overflow: hidden; white-space: nowrap; user-select: none;
cursor: grab; overflow: hidden; white-space: nowrap; user-select: none;
box-sizing: border-box;
}
.clip:hover { filter: brightness(1.04); }
@@ -1403,7 +1428,9 @@
background: var(--heat-12); border-color: var(--heat-40); color: var(--heat);
}
.clip.video .frames {
position: absolute; inset: 0;
position: absolute; top: 0; bottom: 0; left: 0;
width: var(--src-width, 100%);
transform: translateX(var(--src-offset, 0%));
display: flex; gap: 0;
pointer-events: none; z-index: 0;
border-radius: inherit;
@@ -1489,15 +1516,26 @@
}
/* 拖拽时片段移动光标 */
.clip { cursor: pointer; }
.clip { cursor: grab; }
.clip.selected { cursor: grab; }
.clip:active,
.clip.selected:active { cursor: grabbing; }
.clip.dragging { cursor: grabbing; opacity: .9; z-index: 7 !important; }
/* Playhead · 顶到时间尺、贯穿三条轨 · 可拖拽 */
.playhead {
position: absolute; top: -90px; bottom: -44px;
width: 1.5px; background: var(--heat);
width: 18px; transform: translateX(-50%);
background: transparent;
z-index: 10;
pointer-events: auto;
cursor: ew-resize;
touch-action: none;
}
.playhead::after {
content: ''; position: absolute; top: 0; bottom: 0; left: 50%;
transform: translateX(-50%);
width: 1.5px; background: var(--heat);
pointer-events: none;
}
.playhead::before {
@@ -1510,11 +1548,11 @@
}
.playhead .ph-grab {
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
width: 18px; height: 18px;
width: 24px; height: 24px;
cursor: ew-resize; pointer-events: auto;
border-radius: 50%;
}
.playhead.is-dragging { background: var(--heat); }
.playhead.is-dragging::after { background: var(--heat); }
.timeline.is-dragging-playhead { cursor: ew-resize; user-select: none; }
/* Ruler 可点击 seek */
@@ -1882,11 +1920,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 1 · 深夜办公桌</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">15s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 1 · 深夜办公桌</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">15s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@@ -1896,11 +1935,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 2 · 面膜包装/特写</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">12s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 2 · 面膜包装/特写</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">12s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@@ -1910,11 +1950,12 @@
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 3 · 化妆台/产品定格</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">13s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
<div class="video-card-head"><strong class="video-card-title">场 3 · 化妆台/产品定格</strong><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="video-meta">13s · 1080×1920 · ¥0.45</div>
<div class="video-actions">
<button class="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
</div>
</div>
</div>
@@ -4497,15 +4538,19 @@ function openTriLightbox(ver, isAdopted, prodName) {
const sec = s - m * 60;
return String(m).padStart(2,'0') + ':' + sec.toFixed(2).padStart(5,'0');
}
// 磁吸时间轴 · data-dur (当前时长 · 秒) + data-max (源最大 · 可恢复至此)
// 时间轴数据 · data-start(时间线起点) + data-dur(当前时长) + data-max(源最大) + data-in(源素材入点)
const MIN_DUR = 0.2;
const $laneB = document.querySelector('#ed-timeline .bgm-track .lane');
const lanes = { video: $laneV, subtitle: $laneS, bgm: $laneB };
const ALIGN_EPS = 0.12;
const $alignGuide = document.createElement('span');
$alignGuide.className = 'tl-align-guide';
$tl.appendChild($alignGuide);
const D = (c) => Number(c.dataset.dur || 1);
const M = (c) => Number(c.dataset.max || 1);
const setD = (c, v) => { c.dataset.dur = String(Math.max(MIN_DUR, Math.min(M(c), v))); };
const IN = (c) => Number(c.dataset.in || 0);
function clipDur(c) { return D(c); }
function clipStart(c) {
function compactStart(c) {
let acc = 0;
for (const sib of c.parentElement.querySelectorAll('.clip')) {
if (sib === c) return acc;
@@ -4513,19 +4558,171 @@ function openTriLightbox(ver, isAdopted, prodName) {
}
return 0;
}
function clipStart(c) {
const start = Number(c.dataset.start);
return Number.isFinite(start) ? start : compactStart(c);
}
function clipEnd(c) { return clipStart(c) + D(c); }
// 磁吸布局:每轨片段按 DOM 顺序紧贴排列 · 无 gap
function sourceDur(c) { return Math.max(M(c), D(c), MIN_DUR); }
function clampSourceIn(c, sourceIn = IN(c)) {
const maxIn = Math.max(0, sourceDur(c) - D(c));
return Math.max(0, Math.min(maxIn, sourceIn));
}
function syncVideoPreview(c) {
if (!c.classList.contains('video')) return;
const dur = Math.max(D(c), MIN_DUR);
const srcDur = Math.max(sourceDur(c), dur);
const sourceIn = clampSourceIn(c);
c.dataset.in = String(sourceIn);
c.style.setProperty('--src-width', (srcDur / dur * 100) + '%');
c.style.setProperty('--src-offset', (srcDur ? (-(sourceIn / srcDur) * 100) : 0) + '%');
}
function freezeLaneStarts(lane) {
let acc = 0;
for (const clip of lane.querySelectorAll('.clip')) {
const explicit = Number(clip.dataset.start);
const start = Number.isFinite(explicit) ? explicit : acc;
clip.dataset.start = String(start);
acc = start + D(clip);
}
}
function compactLane(lane) {
let acc = 0;
for (const clip of lane.querySelectorAll('.clip')) {
clip.dataset.start = String(acc);
acc += D(clip);
}
layoutLane(lane);
}
function snapLane(lane) {
lane.classList.add('is-snapping');
compactLane(lane);
window.setTimeout(() => lane.classList.remove('is-snapping'), 180);
}
function settleLane(lane) {
if (lane === lanes.video) snapLane(lane);
else layoutLane(lane);
}
// 绝对布局:默认紧贴排列;trim 后允许保留被裁剪端的可见空隙
function layoutLane(lane) {
let acc = 0;
for (const clip of lane.querySelectorAll('.clip')) {
const dur = D(clip);
clip.style.left = (acc / TOTAL * 100) + '%';
const explicit = Number(clip.dataset.start);
const start = Number.isFinite(explicit) ? explicit : acc;
clip.style.left = (start / TOTAL * 100) + '%';
clip.style.width = (dur / TOTAL * 100) + '%';
acc += dur;
syncVideoPreview(clip);
acc = start + dur;
}
}
function layoutAll() {
Object.values(lanes).forEach(l => l && layoutLane(l));
function videoBoundaries() {
const points = [];
$laneV.querySelectorAll('.clip.video').forEach(c => {
points.push(clipStart(c), clipEnd(c));
});
return points;
}
function nearestVideoBoundary(edges) {
let best = null;
const points = videoBoundaries();
edges.forEach((edge, edgeIndex) => {
points.forEach(time => {
const diff = Math.abs(edge - time);
if (diff <= ALIGN_EPS && (!best || diff < best.diff)) {
best = { edge, edgeIndex, time, diff };
}
});
});
return best;
}
function showAlignGuideAt(time) {
const laneRect = $laneV.getBoundingClientRect();
const tlRect = $tl.getBoundingClientRect();
const rulerRect = $ruler.getBoundingClientRect();
const lastLane = $laneB || $laneS || $laneV;
const lastRect = lastLane.getBoundingClientRect();
$alignGuide.style.left = (laneRect.left + (time / TOTAL) * laneRect.width - tlRect.left) + 'px';
$alignGuide.style.top = (rulerRect.top - tlRect.top) + 'px';
$alignGuide.style.height = (lastRect.bottom - rulerRect.top) + 'px';
$alignGuide.classList.add('show');
}
function hideAlignGuide() {
$alignGuide.classList.remove('show');
}
function guideEdgeToVideo(edge) {
const match = nearestVideoBoundary([edge]);
if (!match) {
hideAlignGuide();
return null;
}
showAlignGuideAt(match.time);
return match.time;
}
function guideClipToVideo(start, dur) {
const safeDur = Math.max(MIN_DUR, Math.min(dur, TOTAL));
const clampedStart = Math.max(0, Math.min(TOTAL - safeDur, start));
const match = nearestVideoBoundary([clampedStart, clampedStart + safeDur]);
if (!match) {
hideAlignGuide();
return clampedStart;
}
const nextStart = match.edgeIndex === 0 ? match.time : match.time - safeDur;
showAlignGuideAt(match.time);
return Math.max(0, Math.min(TOTAL - safeDur, nextStart));
}
function ensureDropGhost(lane) {
let ghost = lane.querySelector('.tl-insert-ghost');
if (!ghost) {
ghost = document.createElement('span');
ghost.className = 'tl-insert-ghost';
lane.appendChild(ghost);
}
return ghost;
}
function removeDropGhost(lane) {
lane.querySelector('.tl-insert-ghost')?.remove();
}
function placeDropGhost(ghost, start, dur) {
ghost.style.left = (start / TOTAL * 100) + '%';
ghost.style.width = (dur / TOTAL * 100) + '%';
}
function insertIndexForCenter(siblings, centerT) {
let acc = 0;
for (let i = 0; i < siblings.length; i++) {
const mid = acc + D(siblings[i]) / 2;
if (centerT < mid) return i;
acc += D(siblings[i]);
}
return siblings.length;
}
function previewReorder(lane, dragged, insertIndex) {
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);
const ghost = ensureDropGhost(lane);
let acc = 0;
for (let i = 0; i <= siblings.length; i++) {
if (i === insertIndex) {
placeDropGhost(ghost, acc, D(dragged));
acc += D(dragged);
}
const sibling = siblings[i];
if (sibling) {
sibling.dataset.start = String(acc);
sibling.style.left = (acc / TOTAL * 100) + '%';
sibling.style.width = (D(sibling) / TOTAL * 100) + '%';
syncVideoPreview(sibling);
acc += D(sibling);
}
}
}
function commitReorder(lane, dragged, insertIndex) {
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);
dragged.remove();
if (insertIndex >= siblings.length) lane.appendChild(dragged);
else lane.insertBefore(dragged, siblings[insertIndex]);
}
function compactAll() {
Object.values(lanes).forEach(l => l && compactLane(l));
}
function clipAtTimeOnTrack(track, t) {
const lane = lanes[track];
@@ -4601,8 +4798,10 @@ function openTriLightbox(ver, isAdopted, prodName) {
if (_bodyDragMoved) return; // 拖动重排刚结束 · 抑制 click
e.stopPropagation();
selectClip(clip);
currentTime = clipStart(clip);
updateTimeUI();
if (clip.dataset.track === 'video') {
currentTime = clipStart(clip);
updateTimeUI();
}
});
});
}
@@ -4615,7 +4814,6 @@ function openTriLightbox(ver, isAdopted, prodName) {
updateTimeUI();
}
$laneV.addEventListener('click', e => laneSeek($laneV, e));
$laneS.addEventListener('click', e => laneSeek($laneS, e));
$ruler.addEventListener('click', (e) => {
const rect = $ruler.getBoundingClientRect();
@@ -4625,23 +4823,35 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
let dragging = false;
$playhead.querySelector('.ph-grab').addEventListener('mousedown', (e) => {
let playheadDragOffset = 0;
function playheadCenterX() {
const rect = $laneS.getBoundingClientRect();
return rect.left + (currentTime / TOTAL) * rect.width;
}
function seekPlayhead(clientX) {
const rect = $laneS.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - playheadDragOffset - rect.left) / rect.width));
currentTime = pct * TOTAL;
updateTimeUI();
}
function startPlayheadDrag(e) {
e.preventDefault();
e.stopPropagation();
dragging = true;
playheadDragOffset = e.clientX - playheadCenterX();
if (playing) pause();
$playhead.classList.add('is-dragging');
$tl.classList.add('is-dragging-playhead');
});
}
$playhead.addEventListener('mousedown', startPlayheadDrag);
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = $laneS.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
currentTime = pct * TOTAL;
updateTimeUI();
seekPlayhead(e.clientX);
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
playheadDragOffset = 0;
$playhead.classList.remove('is-dragging');
$tl.classList.remove('is-dragging-playhead');
});
@@ -4707,12 +4917,14 @@ function openTriLightbox(ver, isAdopted, prodName) {
function deleteSelected() {
if (!selectedEl) return;
const lane = selectedEl.parentElement;
const next = selectedEl.nextElementSibling?.classList.contains('clip')
? selectedEl.nextElementSibling
: selectedEl.previousElementSibling?.classList.contains('clip')
? selectedEl.previousElementSibling
: null;
selectedEl.remove();
settleLane(lane);
if (next) selectClip(next);
else { selectedEl = null; updateInspector(); updateActionButtons(); }
renumberVideo();
@@ -4727,6 +4939,10 @@ function openTriLightbox(ver, isAdopted, prodName) {
dup.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete dup.dataset.boundClick;
selectedEl.after(dup);
if (selectedEl.parentElement !== lanes.video) {
dup.dataset.start = String(Math.max(0, Math.min(TOTAL - D(dup), clipEnd(selectedEl))));
}
settleLane(selectedEl.parentElement);
bindClipClicks();
renumberVideo();
selectClip(dup);
@@ -4743,12 +4959,16 @@ function openTriLightbox(ver, isAdopted, prodName) {
// 左半:dur=max=leftDur(切开后双方各自独立 · 不能再恢复跨越切点)
selectedEl.dataset.dur = String(leftDur);
selectedEl.dataset.max = String(leftDur);
selectedEl.dataset.in = '0';
selectedEl.dataset.start = String(s);
const right = selectedEl.cloneNode(true);
right.classList.remove('selected');
right.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete right.dataset.boundClick;
right.dataset.dur = String(rightDur);
right.dataset.max = String(rightDur);
right.dataset.in = '0';
right.dataset.start = String(s + leftDur);
// 右半若是视频片段 · 重新生成胶卷帧条数量(按时长成比例)
if (right.classList.contains('video')) {
const oldFrames = right.querySelector('.frames');
@@ -4776,32 +4996,93 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
}
// Trim 把手 · 三轨通用 · 磁吸:邻居自动跟随前移/后退
// Trim 把手 · 三轨通用 · 左右边界按真实裁剪方向反馈
document.addEventListener('mousedown', (e) => {
const handle = e.target.closest('[data-trim]');
if (!handle || !selectedEl) return;
e.preventDefault(); e.stopPropagation();
const side = handle.dataset.trim;
const lane = selectedEl.parentElement;
const isVideoTrack = lane === lanes.video;
freezeLaneStarts(lane);
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
const startStart = clipStart(selectedEl);
const startDur = D(selectedEl);
const startEnd = startStart + startDur;
const startIn = clampSourceIn(selectedEl);
const startSourceDur = sourceDur(selectedEl);
const startOut = Math.max(0, startSourceDur - startIn - startDur);
const prevClip = selectedEl.previousElementSibling?.classList.contains('clip') ? selectedEl.previousElementSibling : null;
const nextClip = selectedEl.nextElementSibling?.classList.contains('clip') ? selectedEl.nextElementSibling : null;
const prevEnd = prevClip ? clipEnd(prevClip) : 0;
const nextStart = nextClip ? clipStart(nextClip) : TOTAL;
let didTrim = false;
function onMove(ev) {
const dx = ev.clientX - startMouseX;
if (Math.abs(dx) > 1) didTrim = true;
const dt = (dx / laneRect.width) * TOTAL;
// 左把手:右拖缩短 / 左拖恢复(直至 max)
// 右把手:右拖扩长(直至 max)/ 左拖缩短
const newDur = side === 'l' ? (startDur - dt) : (startDur + dt);
setD(selectedEl, newDur);
if (!isVideoTrack) {
if (side === 'l') {
const maxStart = startEnd - MIN_DUR;
let newStart = Math.max(0, Math.min(maxStart, startStart + dt));
const guidedStart = guideEdgeToVideo(newStart);
if (guidedStart !== null) newStart = Math.max(0, Math.min(maxStart, guidedStart));
selectedEl.dataset.start = String(newStart);
selectedEl.dataset.dur = String(Math.max(MIN_DUR, startEnd - newStart));
} else {
const minEnd = startStart + MIN_DUR;
let newEnd = Math.max(minEnd, Math.min(TOTAL, startEnd + dt));
const guidedEnd = guideEdgeToVideo(newEnd);
if (guidedEnd !== null) newEnd = Math.max(minEnd, Math.min(TOTAL, guidedEnd));
selectedEl.dataset.start = String(startStart);
selectedEl.dataset.dur = String(Math.max(MIN_DUR, newEnd - startStart));
}
layoutLane(lane);
updateInspector();
updateActionButtons();
return;
}
hideAlignGuide();
if (side === 'l') {
const minStart = Math.max(prevEnd, startStart - startIn);
const maxStart = startEnd - MIN_DUR;
const newStart = Math.max(minStart, Math.min(maxStart, startStart + dt));
const newDur = Math.max(MIN_DUR, startEnd - newStart);
const newIn = Math.max(0, Math.min(startSourceDur - newDur, startIn + (newStart - startStart)));
selectedEl.dataset.start = String(newStart);
selectedEl.dataset.dur = String(newDur);
selectedEl.dataset.in = String(newIn);
} else {
const minEnd = startStart + MIN_DUR;
const maxEnd = Math.min(nextStart, startEnd + startOut, TOTAL);
const newEnd = Math.max(minEnd, Math.min(maxEnd, startEnd + dt));
selectedEl.dataset.start = String(startStart);
selectedEl.dataset.dur = String(newEnd - startStart);
selectedEl.dataset.in = String(startIn);
}
layoutLane(lane);
updateInspector();
updateActionButtons();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
updateTimeUI();
hideAlignGuide();
if (isVideoTrack) {
snapLane(lane);
if (didTrim && selectedEl) {
currentTime = side === 'l' ? clipStart(selectedEl) : clipEnd(selectedEl);
}
updateInspector();
updateTimeUI();
} else {
layoutLane(lane);
updateInspector();
updateActionButtons();
}
}
document.body.style.cursor = 'ew-resize';
document.addEventListener('mousemove', onMove);
@@ -4812,49 +5093,78 @@ function openTriLightbox(ver, isAdopted, prodName) {
let _bodyDragMoved = false;
document.addEventListener('mousedown', (e) => {
const clip = e.target.closest('#ed-timeline .clip');
if (!clip || !clip.classList.contains('selected')) return;
if (!clip) return;
if (e.target.closest('[data-trim]')) return;
if (e.target.closest('.ph-grab')) return;
e.preventDefault();
_bodyDragMoved = false;
if (clip !== selectedEl) selectClip(clip);
const lane = clip.parentElement;
const isVideoTrack = lane === lanes.video;
freezeLaneStarts(lane);
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
const startStartT = clipStart(clip);
const clipDuration = D(clip);
let dropIndex = null;
function onMove(ev) {
const dx = ev.clientX - startMouseX;
if (!_bodyDragMoved && Math.abs(dx) < 5) return;
_bodyDragMoved = true;
clip.style.transform = 'translateX(' + dx + 'px)';
clip.style.zIndex = '6';
clip.style.opacity = '.88';
clip.classList.add('dragging');
const dt = (dx / laneRect.width) * TOTAL;
if (!isVideoTrack) {
const previewStart = guideClipToVideo(startStartT + dt, clipDuration);
clip.dataset.start = String(previewStart);
clip.style.left = (previewStart / TOTAL * 100) + '%';
clip.style.width = (clipDuration / TOTAL * 100) + '%';
updateInspector();
updateActionButtons();
document.body.style.cursor = 'grabbing';
return;
}
hideAlignGuide();
lane.classList.add('is-reordering');
const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
dropIndex = insertIndexForCenter(siblings, newCenter);
previewReorder(lane, clip, dropIndex);
const previewStart = Math.max(0, Math.min(TOTAL - clipDuration, newCenter - clipDuration / 2));
clip.dataset.start = String(previewStart);
clip.style.left = (previewStart / TOTAL * 100) + '%';
clip.style.width = (clipDuration / TOTAL * 100) + '%';
syncVideoPreview(clip);
document.body.style.cursor = 'grabbing';
}
function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
clip.style.transform = '';
clip.style.zIndex = '';
clip.style.opacity = '';
removeDropGhost(lane);
lane.classList.remove('is-reordering');
clip.classList.remove('dragging');
document.body.style.cursor = '';
hideAlignGuide();
if (!_bodyDragMoved) return;
const dx = ev.clientX - startMouseX;
const dt = (dx / laneRect.width) * TOTAL;
const newCenter = startStartT + D(clip) / 2 + dt;
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
let acc = 0;
let insertBefore = null;
for (const s of siblings) {
const mid = acc + D(s) / 2;
if (newCenter < mid) { insertBefore = s; break; }
acc += D(s);
if (!isVideoTrack) {
layoutLane(lane);
selectClip(clip);
updateInspector();
updateActionButtons();
setTimeout(() => { _bodyDragMoved = false; }, 50);
return;
}
clip.remove();
if (insertBefore) lane.insertBefore(clip, insertBefore);
else lane.appendChild(clip);
layoutLane(lane);
if (dropIndex === null) {
const dx = ev.clientX - startMouseX;
const dt = (dx / laneRect.width) * TOTAL;
const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
dropIndex = insertIndexForCenter(siblings, newCenter);
}
commitReorder(lane, clip, dropIndex);
snapLane(lane);
if (lane === lanes.video) renumberVideo();
selectClip(clip);
updateTimeUI();
setTimeout(() => { _bodyDragMoved = false; }, 50);
}
@@ -4863,7 +5173,7 @@ function openTriLightbox(ver, isAdopted, prodName) {
});
// 初始化:磁吸布局 + 绑定
layoutAll();
compactAll();
bindClipClicks();
updateTimeUI();
})();