1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2import { type RefObject, useEffect, useEffectEvent, useSyncExternalStore } from "react";
3
4/**
5 * Dragging a sidebar thread past the list edge turns the sort gesture into a context drop.
6 * The composer form marks itself as the target; the sidebar hit-tests the pointer against it
7 * and hands over the dragged thread refs through a DOM event, so neither side imports the
8 * other. Ghost position lives here rather than in Sidebar state so pointer moves only
9 * re-render the ghost.
10 */
11
12export const THREAD_CONTEXT_DROP_EVENT = "t3-thread-context-drop";
13
14export function useThreadContextDrop(
15 targetRef: RefObject<EventTarget | null>,
16 environmentId: EnvironmentId,
17 targetKey: string,
18 onDrop: (refs: ReadonlyArray<ScopedThreadRef>, isActive: () => boolean) => void,
19) {
20 const handleDrop = useEffectEvent(onDrop);
21 useEffect(() => {
22 const target = targetRef.current;
23 if (!target) return;
24 let active = true;
25 const listener = (event: Event) =>
26 handleDrop((event as CustomEvent<ReadonlyArray<ScopedThreadRef>>).detail, () => active);
27 target.addEventListener(THREAD_CONTEXT_DROP_EVENT, listener);
28 return () => {
29 active = false;
30 target.removeEventListener(THREAD_CONTEXT_DROP_EVENT, listener);
31 };
32 }, [environmentId, targetKey, targetRef]);
33}
34
35const DROP_TARGET_ATTRIBUTE = "data-thread-context-drop";
36const DROP_OVER_ATTRIBUTE = "data-thread-context-over";
37
38export interface ThreadContextDragGhost {
39 readonly x: number;
40 readonly y: number;
41 readonly title: string;
42 readonly count: number;
43}
44
45let ghost: ThreadContextDragGhost | null = null;
46const listeners = new Set<() => void>();
47
48function setGhost(next: ThreadContextDragGhost | null) {
49 if (ghost === next) return;
50 ghost = next;
51 for (const listener of listeners) listener();
52}
53
54export function useThreadContextDragGhost(): ThreadContextDragGhost | null {
55 return useSyncExternalStore(
56 (listener) => {
57 listeners.add(listener);
58 return () => listeners.delete(listener);
59 },
60 () => ghost,
61 () => null,
62 );
63}
64
65let overTarget: HTMLElement | null = null;
66
67function findDropTarget(point: { x: number; y: number }): HTMLElement | null {
68 return (
69 document
70 .elementFromPoint(point.x, point.y)
71 ?.closest<HTMLElement>(`[${DROP_TARGET_ATTRIBUTE}]`) ?? null
72 );
73}
74
75/** Tracks the ghost and the highlighted drop target while the pointer is outside the list. */
76export function moveThreadContextDrag(
77 point: { x: number; y: number },
78 label: { title: string; count: number },
79) {
80 const target = findDropTarget(point);
81 if (target !== overTarget) {
82 overTarget?.removeAttribute(DROP_OVER_ATTRIBUTE);
83 target?.setAttribute(DROP_OVER_ATTRIBUTE, "true");
84 overTarget = target;
85 }
86 setGhost({ x: point.x, y: point.y, ...label });
87}
88
89export function endThreadContextDrag() {
90 overTarget?.removeAttribute(DROP_OVER_ATTRIBUTE);
91 overTarget = null;
92 setGhost(null);
93}
94
95/** True when a composer accepted the drop. */
96export function dropThreadContext(
97 point: { x: number; y: number },
98 threads: ReadonlyArray<ScopedThreadRef>,
99): boolean {
100 const target = findDropTarget(point);
101 if (!target || threads.length === 0) return false;
102 target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: threads }));
103 return true;
104}
105
106export function threadContextDropTargetProps() {
107 return { [DROP_TARGET_ATTRIBUTE]: "true" } as const;
108}