|
|
// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록(드래그 이동·리사이즈)
|
|
|
"use client";
|
|
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
|
import { GRID_END_MIN, HSTART, PXH, fromMin, toMin, topOf } from "@/lib/calendar/time";
|
|
|
import type { CalEvent, Calendar } from "@/lib/calendar/types";
|
|
|
|
|
|
export type EvCommit = (
|
|
|
ev: CalEvent,
|
|
|
patch: { date: string; start: string; end: string },
|
|
|
) => void;
|
|
|
|
|
|
export function EvBlock({
|
|
|
ev,
|
|
|
cal,
|
|
|
onClick,
|
|
|
idx = 0,
|
|
|
onCommit,
|
|
|
}: {
|
|
|
ev: CalEvent;
|
|
|
cal: Calendar;
|
|
|
onClick: (ev: CalEvent, e: ReactMouseEvent) => void;
|
|
|
idx?: number; // 종일 일정 세로 스택 인덱스
|
|
|
onCommit?: EvCommit; // 드래그 이동/리사이즈 확정(없으면 드래그 비활성)
|
|
|
}) {
|
|
|
// 종일(시작 시각 없음) 일정은 그리드 상단에 22px 간격으로 쌓는다(NaN 위치 방지).
|
|
|
const allDay = !ev.start;
|
|
|
const tone = cal?.tone ?? "ink"; // 캘린더 누락 시에도 안전
|
|
|
const baseTop = allDay ? idx * 22 : topOf(ev.start);
|
|
|
const baseH = allDay ? 20 : ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH;
|
|
|
const short = allDay || baseH < 38;
|
|
|
const canDrag = !allDay && !!onCommit;
|
|
|
|
|
|
const [active, setActive] = useState(false);
|
|
|
const [vis, setVis] = useState<{ mode: "move" | "resize"; dy: number }>({ mode: "move", dy: 0 });
|
|
|
const dragRef = useRef<{ mode: "move" | "resize"; startX: number; startY: number; moved: boolean } | null>(null);
|
|
|
const suppressClick = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!active) return;
|
|
|
const onMove = (e: MouseEvent) => {
|
|
|
const d = dragRef.current;
|
|
|
if (!d) return;
|
|
|
const dy = e.clientY - d.startY;
|
|
|
if (Math.abs(dy) > 3 || Math.abs(e.clientX - d.startX) > 3) d.moved = true;
|
|
|
setVis({ mode: d.mode, dy });
|
|
|
};
|
|
|
const onUp = (e: MouseEvent) => {
|
|
|
const d = dragRef.current;
|
|
|
dragRef.current = null;
|
|
|
setActive(false);
|
|
|
if (!d || !d.moved || !onCommit) return;
|
|
|
suppressClick.current = true; // 드래그 직후의 click 으로 팝오버가 열리지 않도록
|
|
|
const dy = e.clientY - d.startY;
|
|
|
const deltaMin = Math.round((dy / PXH) * 60 / 15) * 15; // 15분 스냅
|
|
|
const startMin = toMin(ev.start);
|
|
|
const endMin = toMin(ev.end);
|
|
|
const dur = endMin - startMin;
|
|
|
if (d.mode === "move") {
|
|
|
let ns = startMin + deltaMin;
|
|
|
ns = Math.max(HSTART * 60, Math.min(ns, GRID_END_MIN - dur));
|
|
|
// 날짜 변경(주 뷰): 포인터 아래 컬럼의 data-date 로 이동
|
|
|
let date = ev.date;
|
|
|
const under = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
|
|
const col = under?.closest("[data-date]") as HTMLElement | null;
|
|
|
if (col?.dataset.date) date = col.dataset.date;
|
|
|
if (ns === startMin && date === ev.date) return;
|
|
|
onCommit(ev, { date, start: fromMin(ns), end: fromMin(ns + dur) });
|
|
|
} else {
|
|
|
let ne = endMin + deltaMin;
|
|
|
ne = Math.max(startMin + 15, Math.min(ne, GRID_END_MIN));
|
|
|
if (ne === endMin) return;
|
|
|
onCommit(ev, { date: ev.date, start: ev.start, end: fromMin(ne) });
|
|
|
}
|
|
|
};
|
|
|
window.addEventListener("mousemove", onMove);
|
|
|
window.addEventListener("mouseup", onUp);
|
|
|
return () => {
|
|
|
window.removeEventListener("mousemove", onMove);
|
|
|
window.removeEventListener("mouseup", onUp);
|
|
|
};
|
|
|
}, [active, ev, onCommit]);
|
|
|
|
|
|
const beginDrag = (mode: "move" | "resize", e: ReactMouseEvent) => {
|
|
|
if (!canDrag) return;
|
|
|
e.stopPropagation();
|
|
|
e.preventDefault();
|
|
|
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, moved: false };
|
|
|
setVis({ mode, dy: 0 });
|
|
|
setActive(true);
|
|
|
};
|
|
|
|
|
|
let top = baseTop;
|
|
|
let h = baseH;
|
|
|
if (active && vis.mode === "move") top = baseTop + vis.dy;
|
|
|
if (active && vis.mode === "resize") h = Math.max(baseH + vis.dy, 20);
|
|
|
|
|
|
const handleClick = (e: ReactMouseEvent) => {
|
|
|
e.stopPropagation();
|
|
|
if (suppressClick.current) {
|
|
|
suppressClick.current = false;
|
|
|
return;
|
|
|
}
|
|
|
onClick(ev, e);
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
className={
|
|
|
"ev tint-" +
|
|
|
tone +
|
|
|
(allDay ? " allday" : "") +
|
|
|
(ev.soon ? " soon" : "") +
|
|
|
(short ? " short" : "") +
|
|
|
(canDrag ? " draggable" : "") +
|
|
|
(active ? " dragging" : "")
|
|
|
}
|
|
|
style={{ top: top + "px", height: Math.max(h - 3, 20) + "px" }}
|
|
|
onMouseDown={canDrag ? (e) => beginDrag("move", e) : undefined}
|
|
|
onClick={handleClick}
|
|
|
>
|
|
|
<div className="et">{ev.title}</div>
|
|
|
{!allDay && (
|
|
|
<div className="etm">
|
|
|
{ev.start}–{ev.end}
|
|
|
</div>
|
|
|
)}
|
|
|
{canDrag && (
|
|
|
<div className="ev-resize" onMouseDown={(e) => beginDrag("resize", e)} aria-hidden />
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|