277 lines
7.9 KiB
TypeScript
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 },
|
|
}
|
|
}
|