ultisuite-client/lib/drag-context.tsx
2026-05-15 23:51:57 +02:00

274 lines
7.4 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useIsXs } from "@/hooks/use-xs"
import {
getDragPointerSnapshot,
notifyDragPointerMove,
resetDragPointer,
} from "@/lib/drag-pointer-store"
export type EmailDragPhase = "dragging" | "returning"
export type EmailDragState = {
ids: string[]
sourceFolderId: string
hoveredTargetId: string | null
hoveredTargetLabel: string | null
/** Last committed pointer (begin drag + snapshot when entering `returning`). */
pointerX: number
pointerY: number
originX: number
originY: number
phase: EmailDragPhase
}
type OnDropCallback = (targetId: string, targetLabel: string, ids: string[]) => void
type EmailDragContextValue = {
state: EmailDragState | null
beginDrag: (ids: string[], sourceFolderId: string, pointerX: number, pointerY: number) => void
setHoveredTarget: (id: string | null, label: string | null) => void
clearHoveredTarget: (id: string) => void
completeDrop: (targetId: string, targetLabel: string) => void
cancelDrag: () => void
registerOnDrop: (cb: OnDropCallback) => () => void
}
const EmailDragContext = createContext<EmailDragContextValue | null>(null)
/** Duration of the "return to origin" animation when drag is cancelled. */
const RETURN_ANIMATION_MS = 240
export function EmailDragProvider({ children }: { children: React.ReactNode }) {
const isXs = useIsXs()
const [state, setState] = useState<EmailDragState | null>(null)
const stateRef = useRef<EmailDragState | null>(null)
const onDropRef = useRef<OnDropCallback | null>(null)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!isXs) return
stateRef.current = null
setState(null)
resetDragPointer(0, 0)
}, [isXs])
const beginDrag = useCallback(
(ids: string[], sourceFolderId: string, pointerX: number, pointerY: number) => {
if (isXs || ids.length === 0) return
const next: EmailDragState = {
ids,
sourceFolderId,
hoveredTargetId: null,
hoveredTargetLabel: null,
pointerX,
pointerY,
originX: pointerX,
originY: pointerY,
phase: "dragging",
}
stateRef.current = next
resetDragPointer(pointerX, pointerY)
setState(next)
},
[isXs]
)
const setHoveredTarget = useCallback((id: string | null, label: string | null) => {
setState((prev) => {
if (!prev || prev.phase !== "dragging") return prev
if (prev.hoveredTargetId === id) return prev
return { ...prev, hoveredTargetId: id, hoveredTargetLabel: label }
})
}, [])
const clearHoveredTarget = useCallback((id: string) => {
setState((prev) => {
if (!prev) return prev
if (prev.hoveredTargetId !== id) return prev
return { ...prev, hoveredTargetId: null, hoveredTargetLabel: null }
})
}, [])
const cancelDrag = useCallback(() => {
const cur = stateRef.current
if (!cur) return
if (cur.phase === "returning") return
const p = getDragPointerSnapshot()
const next: EmailDragState = {
...cur,
phase: "returning",
hoveredTargetId: null,
hoveredTargetLabel: null,
pointerX: p.x,
pointerY: p.y,
}
stateRef.current = next
setState(next)
}, [])
const completeDrop = useCallback((targetId: string, targetLabel: string) => {
const cur = stateRef.current
if (!cur) return
stateRef.current = null
setState(null)
resetDragPointer(0, 0)
if (targetId !== cur.sourceFolderId && onDropRef.current) {
const cb = onDropRef.current
const ids = cur.ids
queueMicrotask(() => cb(targetId, targetLabel, ids))
}
}, [])
const registerOnDrop = useCallback((cb: OnDropCallback) => {
onDropRef.current = cb
return () => {
if (onDropRef.current === cb) onDropRef.current = null
}
}, [])
// Live pointer during native drag — updates external store only (no React state).
useEffect(() => {
if (!state || state.phase !== "dragging") return
const onDragOver = (e: DragEvent) => {
notifyDragPointerMove(e.clientX, e.clientY)
}
const onDragEnd = () => {
const cur = stateRef.current
if (!cur) return
if (cur.phase === "returning") return
const p = getDragPointerSnapshot()
const next: EmailDragState = {
...cur,
phase: "returning",
hoveredTargetId: null,
hoveredTargetLabel: null,
pointerX: p.x,
pointerY: p.y,
}
stateRef.current = next
setState(next)
}
window.addEventListener("dragover", onDragOver)
window.addEventListener("dragend", onDragEnd)
return () => {
window.removeEventListener("dragover", onDragOver)
window.removeEventListener("dragend", onDragEnd)
}
}, [state])
// Auto-clear after the return animation finishes.
useEffect(() => {
if (state?.phase !== "returning") return
const id = window.setTimeout(() => {
stateRef.current = null
setState(null)
resetDragPointer(0, 0)
}, RETURN_ANIMATION_MS + 20)
return () => window.clearTimeout(id)
}, [state?.phase])
const value = useMemo<EmailDragContextValue>(
() => ({
state,
beginDrag,
setHoveredTarget,
clearHoveredTarget,
completeDrop,
cancelDrag,
registerOnDrop,
}),
[
state,
beginDrag,
setHoveredTarget,
clearHoveredTarget,
completeDrop,
cancelDrag,
registerOnDrop,
]
)
return <EmailDragContext.Provider value={value}>{children}</EmailDragContext.Provider>
}
export function useEmailDrag() {
const ctx = useContext(EmailDragContext)
if (!ctx) {
throw new Error("useEmailDrag must be used inside <EmailDragProvider>")
}
return ctx
}
export const EMAIL_DRAG_RETURN_MS = RETURN_ANIMATION_MS
const noopDropHandlers = {
onDragEnter: () => {},
onDragOver: () => {},
onDragLeave: () => {},
onDrop: () => {},
}
export function useEmailDropTarget(targetId: string, targetLabel: string) {
const isXs = useIsXs()
const { state, setHoveredTarget, clearHoveredTarget, completeDrop } = useEmailDrag()
if (isXs) {
return {
isOver: false,
acceptsDrop: false,
isSource: false,
dropHandlers: noopDropHandlers,
}
}
const isDragging = state !== null && state.phase === "dragging"
const isSource = !!state && state.sourceFolderId === targetId
const acceptsDrop = isDragging && !isSource
const isOver = !!state && state.hoveredTargetId === targetId
const onDragEnter = (e: React.DragEvent) => {
if (!acceptsDrop) return
e.preventDefault()
setHoveredTarget(targetId, targetLabel)
}
const onDragOver = (e: React.DragEvent) => {
if (!acceptsDrop) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = "move"
if (!isOver) setHoveredTarget(targetId, targetLabel)
}
const onDragLeave = (e: React.DragEvent) => {
if (!acceptsDrop) return
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) {
return
}
clearHoveredTarget(targetId)
}
const onDrop = (e: React.DragEvent) => {
if (!acceptsDrop) return
e.preventDefault()
completeDrop(targetId, targetLabel)
}
return {
isOver: acceptsDrop && isOver,
acceptsDrop,
isSource,
dropHandlers: { onDragEnter, onDragOver, onDragLeave, onDrop },
}
}