import { DragDropManager, Droppable } from "@dnd-kit/dom"; import { Sortable } from "@dnd-kit/dom/sortable"; import type { DndManager, DndManagerOptions, DndMoveIntent, SortableRegistration, } from "./types.ts"; export function createDndManager(options: DndManagerOptions): DndManager { const direction = options.direction ?? "ltr"; const reducedMotion = options.reducedMotion ?? false; const entries = new Map< string | number, { sortable: Sortable; initial: { group: string | number; index: number }; current: SortableRegistration; } >(); const manager = new DragDropManager(); const zones = new Map(); let activeIds: Array = []; let generation = 0; let destroyed = false; let keyboardAxis: "x" | "y" | undefined; const keyboardAligned = (id: string | number, target: HTMLElement) => { if (!keyboardAxis) return true; const element = entries.get(id)?.current.element; const a = element?.getBoundingClientRect?.(), b = target.getBoundingClientRect?.(); if (!a || !b) return true; return keyboardAxis === "x" ? Math.min(a.bottom, b.bottom) > Math.max(a.top, b.top) : Math.min(a.right, b.right) > Math.max(a.left, b.left); }; manager.monitor.addEventListener("dragmove", event => { const code = (event.nativeEvent as KeyboardEvent | undefined)?.code; if (code === "ArrowLeft" || code === "ArrowRight") keyboardAxis = "x"; else if (code === "ArrowUp" || code === "ArrowDown") keyboardAxis = "y"; }); manager.monitor.addEventListener("dragstart", (event) => { if (destroyed) return; generation++; keyboardAxis = undefined; const source = event.operation.source; if (!source) return; activeIds = [source.id]; options.announce?.({ type: "pickedUp", ids: activeIds }); }); manager.monitor.addEventListener("dragover", (event) => { if (destroyed) return; const source = event.operation.source as (typeof event.operation.source & { group?: string | number; index?: number }) | null; if (!source) return; const target = event.operation.target as (typeof event.operation.target & { group?: string | number; index?: number }) | null; if (!target) return; const destination = zones.get(target.id)?.location ?? { group: target.group ?? "default", index: target.index ?? 0 }; if (options.accept && !options.accept(activeIds, destination)) { event.preventDefault(); options.announce?.({ type: "invalid", ids: activeIds, location: destination }); } else options.announce?.({ type: "movedOver", ids: activeIds, location: destination }); }); manager.monitor.addEventListener("dragend", async (event) => { if (destroyed) return; const endedGeneration = generation; const ids = [...activeIds]; const source = event.operation.source as | (typeof event.operation.source & { group?: string | number; index?: number; initialGroup?: string | number; initialIndex?: number; }) | null; if (!source || event.operation.canceled) { options.announce?.({ type: "cancelled", ids }); activeIds = []; return; } const intent: DndMoveIntent = { ids, from: [ { group: source.initialGroup ?? source.group ?? "default", index: source.initialIndex ?? source.index ?? 0, }, ], to: { ...(event.operation.target ? zones.get(event.operation.target.id)?.location : undefined) ?? { group: source.group ?? "default", index: source.index ?? 0 } }, }; // A pending commit must not own the next gesture's selection or lifecycle. activeIds = []; let accepted: boolean | void; try { accepted = options.accept && !options.accept(ids, intent.to) ? false : await options.onMoveIntent(intent); } catch (error) { if (!destroyed && generation === endedGeneration) { options.announce?.({ type: "cancelled", ids, location: intent.to }); options.onError?.(error, intent); } return; } if (destroyed || generation !== endedGeneration) return; options.announce?.({ type: accepted === false ? "cancelled" : "dropped", ids, location: intent.to, }); }); return { registerDropZone(registration) { if (destroyed) throw new Error("Cannot register a drop zone after manager destruction."); if (entries.has(registration.id) || zones.has(registration.id)) throw new Error(`Duplicate draggable or drop zone id ${String(registration.id)}.`); if (!Number.isSafeInteger(registration.index) || registration.index < 0) throw new Error("Drop zone index must be a non-negative safe integer."); const droppable = new Droppable({ id: registration.id, element: registration.element, accept: source => keyboardAligned(source.id, registration.element) && (!registration.accepts || registration.accepts.some(type => type === source.type)) && (options.accept?.([source.id], { group: registration.group, index: registration.index }) ?? true), disabled: registration.disabled }, manager); zones.set(registration.id, { droppable, location: { group: registration.group, index: registration.index } }); return () => { if (zones.get(registration.id)?.droppable !== droppable) return; droppable.destroy(); zones.delete(registration.id); }; }, registerSortable(registration) { if (destroyed) throw new Error("Cannot register a sortable after manager destruction."); if (entries.has(registration.id) || zones.has(registration.id)) throw new Error(`Duplicate sortable id ${String(registration.id)}.`); const sortable = new Sortable( { id: registration.id, group: registration.group, index: registration.index, element: registration.element, handle: registration.handle, type: registration.type, accept: (source) => { if (!keyboardAligned(source.id, registration.element)) return false; const origin = entries.get(source.id)?.current.element; // A nested ancestor overlaps its own descendants and must not steal // their next keyboard/pointer target just because its box is larger. if (source.id !== registration.id && origin && registration.element.contains?.(origin)) return false; if (registration.accepts && !registration.accepts.some(type => type === source.type)) return false; const target = entries.get(registration.id)?.sortable; return options.accept?.([source.id], { group: target?.group ?? registration.group, index: target?.index ?? registration.index }) ?? true; }, disabled: registration.disabled, transition: reducedMotion ? null : undefined, data: { ...registration.data, direction }, }, manager, ); entries.set(registration.id, { sortable, initial: { group: registration.group, index: registration.index }, current: registration, }); return () => { // Cleanup can run after refresh or be called more than once. if (entries.get(registration.id)?.sortable !== sortable) return; sortable.destroy(); entries.delete(registration.id); }; }, destroy() { if (destroyed) return; destroyed = true; generation++; activeIds = []; for (const entry of entries.values()) entry.sortable.destroy(); entries.clear(); for (const zone of zones.values()) zone.droppable.destroy(); zones.clear(); manager.destroy(); }, }; }