fix(project): 달력 드래그를 포인터 기반으로 재구현

HTML5 네이티브 DnD 가 절대배치 grid 오버레이 달력에서 dragstart 를 잡지
못해(콘솔 에러 없이 무동작) 드래그가 안 됐다. mousedown→mousemove→mouseup
포인터 기반으로 직접 구현:
- 5px 이상 움직이면 드래그 시작, 고스트 라벨이 커서 따라다님
- 드래그 중 bar pointer-events 해제 → elementFromPoint 가 날짜 셀 반환
- 놓은 칸의 날짜로 기간 유지 이동, 안 움직이면 클릭=모달
- 네이티브 draggable 속성 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:57:56 +09:00
parent 7a85367db8
commit 105e036573
5 changed files with 61 additions and 60 deletions
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -45,5 +45,5 @@
{% endif %}
</div>
<script src="/static/project.js?v=20260625g" defer></script>
<script src="/static/project.js?v=20260625h" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
@@ -101,5 +101,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625g" defer></script>
<script src="/static/project.js?v=20260625h" defer></script>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625g" />
<link rel="stylesheet" href="/static/project.css?v=20260625h" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
@@ -119,7 +119,6 @@
{% if bar.continues_left %}pj-bar-l{% endif %}
{% if bar.continues_right %}pj-bar-r{% endif %}"
data-task-id="{{ bar.id }}"
{% if can_manage %}draggable="true"{% endif %}
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};
background: {% if bar.completed %}#9aa5b1{% else %}{{ bar.color }}{% endif %};"
title="{{ bar.label }}">
@@ -257,5 +256,5 @@
</script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625g" defer></script>
<script src="/static/project.js?v=20260625h" defer></script>
{% endblock %}
+5 -2
View File
@@ -137,10 +137,13 @@
.pj-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
.pj-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
.pj-bar-done { opacity: .75; text-decoration: line-through; }
/* 드래그 이동 — 드래그 중엔 bar 가 이벤트 가로채지 않게(날짜 셀이 드롭 받도록) */
/* 드래그 이동(포인터 기반) — 드래그 중엔 bar 가 이벤트 가로채지 않게(elementFromPoint 가 날짜 셀 반환) */
.pj-cal-dragging { cursor: grabbing; }
.pj-cal-dragging .pj-bar { pointer-events: none; }
.pj-bar[draggable="true"] { cursor: grab; }
.pj-project-view[data-can-manage="true"] .pj-bar { cursor: grab; }
.pj-day.pj-drop-over { background: #e6ecfa; box-shadow: inset 0 0 0 2px #aebfe6; }
/* 드래그 중 따라다니는 고스트 라벨 */
.pj-bar-ghost { position: fixed; z-index: 2000; pointer-events: none; padding: 2px 10px; border-radius: 6px; background: #1e1f21; color: #fff; font-size: 12px; box-shadow: 0 4px 14px rgba(0,0,0,.3); white-space: nowrap; }
/* ── 타임라인 컨테이너 ── */
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
+49 -50
View File
@@ -142,22 +142,16 @@
return Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / 86400000);
}
let dragTaskId = null;
// 한 주 안에서 clientX → 0..6 열 인덱스 → 그 칸의 날짜.
function dateAtPointer(week, clientX) {
const cells = week.querySelectorAll(".pj-week-days .pj-day[data-date]");
if (!cells.length) return null;
const rect = week.getBoundingClientRect();
let col = Math.floor((clientX - rect.left) / (rect.width / 7));
col = Math.max(0, Math.min(cells.length - 1, col));
return { date: cells[col].dataset.date, cell: cells[col] };
}
function clearDropHL() {
document.querySelectorAll(".pj-day.pj-drop-over").forEach(function (d) { d.classList.remove("pj-drop-over"); });
}
// 화면 좌표 → 그 위치의 날짜 셀(있으면).
function dayCellAt(x, y) {
const elAt = document.elementFromPoint(x, y);
return elAt ? elAt.closest(".pj-day[data-date]") : null;
}
async function moveTaskTo(taskId, dropDate) {
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!t) return;
@@ -175,52 +169,57 @@
} catch (err) { alert("이동 실패: " + err.message); }
}
// 네이티브 HTML5 드래그는 이 절대배치 grid 오버레이에서 불안정해서,
// 포인터(mousedown→mousemove→mouseup) 기반으로 직접 구현한다.
function wireCalendar() {
if (calendarWired) return;
calendarWired = true;
const cal = document.querySelector(".pj-cal");
document.querySelectorAll(".pj-bar[data-task-id]").forEach(function (bar) {
bar.addEventListener("click", function (e) {
e.preventDefault();
if (!canManage) return;
const t = tasks.find(function (x) { return String(x.id) === bar.dataset.taskId; });
if (t) openTaskModal(t);
});
if (canManage) {
bar.draggable = true;
bar.addEventListener("dragstart", function (e) {
dragTaskId = bar.dataset.taskId;
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", bar.dataset.taskId); } catch (_) {}
if (cal) cal.classList.add("pj-cal-dragging");
});
bar.addEventListener("dragend", function () {
dragTaskId = null;
if (cal) cal.classList.remove("pj-cal-dragging");
clearDropHL();
});
}
});
bar.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
e.preventDefault(); // 텍스트 선택/네이티브 드래그 방지
const taskId = bar.dataset.taskId;
const sx = e.clientX, sy = e.clientY;
let dragging = false, ghost = null;
if (!canManage) return;
// 드롭 대상은 주(week) 단위로 받고, 마우스 X 로 어느 날짜 칸인지 계산.
// (bar 오버레이/pointer-events 간섭 없이 안정적으로 드롭됨)
document.querySelectorAll(".pj-week").forEach(function (week) {
week.addEventListener("dragover", function (e) {
if (!dragTaskId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const hit = dateAtPointer(week, e.clientX);
if (hit) { clearDropHL(); hit.cell.classList.add("pj-drop-over"); }
});
week.addEventListener("drop", function (e) {
e.preventDefault();
const taskId = dragTaskId || e.dataTransfer.getData("text/plain");
function onMove(ev) {
if (!dragging) {
if (Math.abs(ev.clientX - sx) + Math.abs(ev.clientY - sy) < 5) return;
dragging = true;
if (cal) cal.classList.add("pj-cal-dragging");
if (canManage) {
ghost = document.createElement("div");
ghost.className = "pj-bar-ghost";
ghost.textContent = (bar.textContent || "").trim();
document.body.appendChild(ghost);
}
}
if (ghost) { ghost.style.left = (ev.clientX + 10) + "px"; ghost.style.top = (ev.clientY + 12) + "px"; }
clearDropHL();
if (!taskId) return;
const hit = dateAtPointer(week, e.clientX);
if (hit) moveTaskTo(taskId, hit.date);
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) cell.classList.add("pj-drop-over");
}
function onUp(ev) {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (cal) cal.classList.remove("pj-cal-dragging");
if (ghost) ghost.remove();
clearDropHL();
const t = tasks.find(function (x) { return String(x.id) === String(taskId); });
if (!dragging) { // 안 움직였으면 클릭 = 모달
if (t && canManage) openTaskModal(t);
return;
}
if (!canManage) return;
const cell = dayCellAt(ev.clientX, ev.clientY);
if (cell) moveTaskTo(taskId, cell.dataset.date);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
});
}