diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 7e40117..d23b004 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -15,6 +15,7 @@ import type { import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; +import useCalendarScrollBlock from "../hooks/useCalendarScrollBlock"; import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; import type { EditingSlot } from "../pages/eventId/Submission"; @@ -69,6 +70,13 @@ type Props = { const OPACITY = 0.2; const PRIMARY_RGB = [15, 130, 177]; +/** + * 長押しでドラッグ開始とみなすまでの遅延時間 (ms) + * - FullCalendar で選択イベントが点灯し始めるまでの時間。 + * - また、これ以上の時間で押し続けるとドラッグ操作として扱われ、スクロールを無効化する + */ +const LONG_PRESS_DELAY = 150; + const EDITING_EVENT = "ih-editing-event"; const VIEWING_EVENT = "ih-viewing-event"; const SELECT_EVENT = "ih-select-event"; @@ -284,6 +292,8 @@ export const Calendar = ({ }; }, []); + useCalendarScrollBlock(LONG_PRESS_DELAY); + const pageCount = Math.ceil(countDays / 7); const headerToolbar = useMemo( @@ -456,7 +466,7 @@ export const Calendar = ({ ref={calendarRef} plugins={[timeGridPlugin, interactionPlugin]} height={"100%"} - longPressDelay={200} + longPressDelay={LONG_PRESS_DELAY} slotDuration={"00:15:00"} allDaySlot={false} initialDate={startDate} diff --git a/client/src/hooks/useCalendarScrollBlock.ts b/client/src/hooks/useCalendarScrollBlock.ts new file mode 100644 index 0000000..f77ee34 --- /dev/null +++ b/client/src/hooks/useCalendarScrollBlock.ts @@ -0,0 +1,90 @@ +import { useEffect } from "react"; + +/** + * 長押し時にカレンダーのスクロールをブロックする + * @param LONG_PRESS_DELAY 長押しとみなすまでの時間 (ms) + * @param MOVE_TOLERANCE これ以上動いたらスクロールとみなす (px) + */ +export default function useCalendarScrollBlock(LONG_PRESS_DELAY = 150, MOVE_TOLERANCE = 5) { + useEffect(() => { + const wrapper = document.getElementById("ih-cal-wrapper"); + if (!wrapper) return; + const scroller = wrapper.querySelector(".fc-scroller.fc-scroller-liquid-absolute") as HTMLElement | null; + if (!scroller) return; + + let pressTimer: number | null = null; + let isDragMode = false; + let startX = 0; + let startY = 0; + + const clearPressTimer = () => { + if (pressTimer !== null) { + window.clearTimeout(pressTimer); + pressTimer = null; + } + }; + + const resetDragMode = () => { + clearPressTimer(); + if (isDragMode) { + isDragMode = false; + scroller.style.overflowY = ""; + scroller.style.touchAction = ""; + } + }; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]; + startX = t.clientX; + startY = t.clientY; + isDragMode = false; + clearPressTimer(); + + // 一定時間動かなければ、スクロールを無効化 + pressTimer = window.setTimeout(() => { + isDragMode = true; + scroller.style.overflowY = "hidden"; + scroller.style.touchAction = "none"; + }, LONG_PRESS_DELAY); + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - startX); + const dy = Math.abs(t.clientY - startY); + + if (!isDragMode) { + // ロングプレス判定中に大きく動いたらスクロールとみなしてキャンセル + if (dx > MOVE_TOLERANCE || dy > MOVE_TOLERANCE) { + clearPressTimer(); + } + return; + } + + e.preventDefault(); // 今回は overflowY: hidden で十分だが一応 + }; + + const onTouchEnd = () => { + resetDragMode(); + }; + + const onTouchCancel = () => { + resetDragMode(); + }; + + // touchmove で preventDefault するには passive: false が必要 + scroller.addEventListener("touchstart", onTouchStart, { passive: true }); + scroller.addEventListener("touchmove", onTouchMove, { passive: false }); + scroller.addEventListener("touchend", onTouchEnd, { passive: true }); + scroller.addEventListener("touchcancel", onTouchCancel, { passive: true }); + + return () => { + scroller.removeEventListener("touchstart", onTouchStart); + scroller.removeEventListener("touchmove", onTouchMove); + scroller.removeEventListener("touchend", onTouchEnd); + scroller.removeEventListener("touchcancel", onTouchCancel); + }; + }, [LONG_PRESS_DELAY, MOVE_TOLERANCE]); +}