Open History to see the last commit
1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2import { type RefObject, useEffect, useEffectEvent, useSyncExternalStore } from "react";
3
4export const THREAD_CONTEXT_DROP_EVENT = "t3-thread-context-drop";
5
6export function useThreadContextDrop(
7 targetRef: RefObject<EventTarget | null>,
8 environmentId: EnvironmentId,
9 targetKey: string,
10 onDrop: (refs: ReadonlyArray<ScopedThreadRef>, isActive: () => boolean) => void,
11) {
12 const handleDrop = useEffectEvent(onDrop);
13 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes the target element to thread-context drop events and marks old drops inactive
14 useEffect(() => {
15 const target = targetRef.current;
16 if (!target) return;
17 let active = true;
18 const listener = (event: Event) =>
19 handleDrop((event as CustomEvent<ReadonlyArray<ScopedThreadRef>>).detail, () => active);
20 target.addEventListener(THREAD_CONTEXT_DROP_EVENT, listener);
21 return () => {
22 active = false;
23 target.removeEventListener(THREAD_CONTEXT_DROP_EVENT, listener);
24 };
25 }, [
26 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new environment or target ends the previous listener, so isActive reports false to drops still in flight for it.
27 environmentId,
28 targetKey,
29 targetRef,
30 ]);
31}
32
33const DROP_TARGET_ATTRIBUTE = "data-thread-context-drop";
34const DROP_OVER_ATTRIBUTE = "data-thread-context-over";
35
36export interface ThreadContextDragGhost {
37 readonly x: number;
38 readonly y: number;
39 readonly title: string;
40 readonly count: number;
41}
42
43let ghost: ThreadContextDragGhost | null = null;
44const listeners = new Set<() => void>();
45
46function setGhost(next: ThreadContextDragGhost | null) {
47 if (ghost === next) return;
48 ghost = next;
49 for (const listener of listeners) listener();
50}
51
52export function useThreadContextDragGhost(): ThreadContextDragGhost | null {
53 return useSyncExternalStore(
54 (listener) => {
55 listeners.add(listener);
56 return () => listeners.delete(listener);
57 },
58 () => ghost,
59 () => null,
60 );
61}
62
63let overTarget: HTMLElement | null = null;
64
65function findDropTarget(point: { x: number; y: number }): HTMLElement | null {
66 return (
67 document
68 .elementFromPoint(point.x, point.y)
69 ?.closest<HTMLElement>(`[${DROP_TARGET_ATTRIBUTE}]`) ?? null
70 );
71}
72
73export function moveThreadContextDrag(
74 point: { x: number; y: number },
75 label: { title: string; count: number },
76) {
77 const target = findDropTarget(point);
78 if (target !== overTarget) {
79 overTarget?.removeAttribute(DROP_OVER_ATTRIBUTE);
80 target?.setAttribute(DROP_OVER_ATTRIBUTE, "true");
81 overTarget = target;
82 }
83 setGhost({ x: point.x, y: point.y, ...label });
84}
85
86export function endThreadContextDrag() {
87 overTarget?.removeAttribute(DROP_OVER_ATTRIBUTE);
88 overTarget = null;
89 setGhost(null);
90}
91
92export function dropThreadContext(
93 point: { x: number; y: number },
94 threads: ReadonlyArray<ScopedThreadRef>,
95): boolean {
96 const target = findDropTarget(point);
97 if (!target || threads.length === 0) return false;
98 target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: threads }));
99 return true;
100}
101
102export function threadContextDropTargetProps() {
103 return { [DROP_TARGET_ATTRIBUTE]: "true" } as const;
104}