ultisuite-client/lib/drag-context.tsx
2026-05-15 17:40:17 +02:00

277 lines
7.9 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { flushSync } from "react-dom"
import { useIsXs } from "@/hooks/use-xs"
export type EmailDragPhase = "dragging" | "returning"
export type EmailDragState = {
ids: string[]
sourceFolderId: string
hoveredTargetId: string | null
hoveredTargetLabel: string | null
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)
}, [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
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 next: EmailDragState = { ...cur, phase: "returning", hoveredTargetId: null }
stateRef.current = next
flushSync(() => {
setState(next)
})
}, [])
const completeDrop = useCallback((targetId: string, targetLabel: string) => {
const cur = stateRef.current
if (!cur) return
stateRef.current = null
setState(null)
if (targetId !== cur.sourceFolderId && onDropRef.current) {
// Defer to a microtask so we never call setState in another
// component synchronously from within this render/commit phase.
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
}
}, [])
// Track cursor + handle native end-of-drag.
useEffect(() => {
if (!state) return
const onDragOver = (e: DragEvent) => {
setState((prev) =>
prev && prev.phase === "dragging"
? { ...prev, pointerX: e.clientX, pointerY: e.clientY }
: prev
)
}
const onDragEnd = () => {
// If state still active and no drop was accepted, animate return.
// We deliberately keep `pointerX/Y` from the last `dragover` so the
// tween starts from the exact spot where the indicator was last
// painted. Snapping to `e.clientX/Y` would visibly shift the badge
// one frame before the animation begins.
// `flushSync` forces React to commit this state change synchronously
// so the indicator's `useLayoutEffect` (which starts the animation)
// fires in the same paint as the phase flip — no perceptible delay.
const cur = stateRef.current
if (!cur) return
if (cur.phase === "returning") return
const next: EmailDragState = {
...cur,
phase: "returning",
hoveredTargetId: null,
}
stateRef.current = next
flushSync(() => {
setState(next)
})
}
window.addEventListener("dragover", onDragOver)
window.addEventListener("dragend", onDragEnd)
return () => {
window.removeEventListener("dragover", onDragOver)
window.removeEventListener("dragend", onDragEnd)
}
}, [state !== null])
// Auto-clear after the return animation finishes.
useEffect(() => {
if (state?.phase !== "returning") return
const id = window.setTimeout(() => {
stateRef.current = null
setState(null)
}, 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
/**
* Helper for sidebar/folder/label rows. Returns drop-event handlers + a flag
* indicating whether the current drag is hovering this target. If the target
* is the drag source (same id as `sourceFolderId`), the drop is rejected so
* the user just sees a "cancel" and no move happens.
*/
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 },
}
}