Polish editor timeline interactions
This commit is contained in:
+382
-72
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user