You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

133 lines
4.7 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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>
);
}