diff --git a/css/planner.css b/css/planner.css index 02aa87a..689f9ea 100644 --- a/css/planner.css +++ b/css/planner.css @@ -723,6 +723,12 @@ background: var(--bg1); max-width: 100%; width: 100%; + cursor: grab; + user-select: none; +} + +.timeline-scroll--dragging { + cursor: grabbing; } .timeline-grid { diff --git a/js/planner.js b/js/planner.js index d6f283a..f5f6dee 100644 --- a/js/planner.js +++ b/js/planner.js @@ -1002,7 +1002,59 @@ function initTimeline(planId) { const settings = document.getElementById('timeline-settings'); if (!timeline || !settings) return; + let timelinePan = null; + let suppressNextTimelineClick = false; + + timeline.addEventListener('pointerdown', e => { + if (e.button !== 0) return; + if (!e.target.closest('.timeline-scroll')) return; + if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return; + + const scroll = e.target.closest('.timeline-scroll'); + timelinePan = { + scroll, + pointerId: e.pointerId, + startX: e.clientX, + startScrollLeft: scroll.scrollLeft, + moved: false, + }; + scroll.setPointerCapture?.(e.pointerId); + }); + + timeline.addEventListener('pointermove', e => { + if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; + const dx = e.clientX - timelinePan.startX; + if (Math.abs(dx) > 3) { + timelinePan.moved = true; + timelinePan.scroll.classList.add('timeline-scroll--dragging'); + timelinePan.scroll.scrollLeft = timelinePan.startScrollLeft - dx; + e.preventDefault(); + } + }); + + timeline.addEventListener('pointerup', e => { + if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; + timelinePan.scroll.releasePointerCapture?.(e.pointerId); + timelinePan.scroll.classList.remove('timeline-scroll--dragging'); + suppressNextTimelineClick = timelinePan.moved; + timelinePan = null; + if (suppressNextTimelineClick) setTimeout(() => { suppressNextTimelineClick = false; }, 0); + }); + + timeline.addEventListener('pointercancel', e => { + if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; + timelinePan.scroll.releasePointerCapture?.(e.pointerId); + timelinePan.scroll.classList.remove('timeline-scroll--dragging'); + timelinePan = null; + }); + timeline.addEventListener('click', e => { + if (suppressNextTimelineClick) { + e.preventDefault(); + e.stopPropagation(); + suppressNextTimelineClick = false; + return; + } closeTimelineMenu(); const boss = e.target.closest('.timeline-boss-action'); if (boss) { @@ -1063,11 +1115,20 @@ function initTimeline(planId) { timeline.addEventListener('dragstart', e => { const block = e.target.closest('.timeline-mitigation'); if (!block) return; + const plan = getPlan(planId); + const found = findTimelineAssignment(plan, { + mechanicId: block.dataset.mechanicId, + ability: block.dataset.ability, + job: block.dataset.job, + }); + if (!plan || !found) return; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, + startClientX: e.clientX, + startTimestamp: assignmentStartMs(found.mechanic, found.assignment), })); }); @@ -1086,8 +1147,9 @@ function initTimeline(planId) { const plan = getPlan(planId); if (!plan) return; const rect = track.getBoundingClientRect(); - const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); - const timestamp = (x / rect.width) * planDurationMs(plan); + const deltaPx = e.clientX - (Number(data.startClientX) || e.clientX); + const timestampDelta = (deltaPx / rect.width) * planDurationMs(plan); + const timestamp = (Number(data.startTimestamp) || 0) + timestampDelta; if (row.dataset.ability && data.ability !== row.dataset.ability) return; updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); });