/** * Shared drag-and-drop utilities for cross-module item dragging. * * Any module card/list-item can become draggable by calling * `makeDraggable(el, payload)` — the calendar and reminders widget * will accept the drop via `application/rspace-item`. * * Uses native HTML5 drag for mouse, and a PointerEvent-based fallback * for touch/pen that synthesizes real DragEvents with a constructed * DataTransfer — drop receivers don't need to change. */ export interface RSpaceItemPayload { title: string; module: string; // e.g. "rnotes", "rtasks", "rfiles" entityId: string; label?: string; // human-readable source, e.g. "Note", "Task" color?: string; // module accent color } /** Module accent colors for drag ghost + calendar indicators */ export const MODULE_COLORS: Record = { rnotes: "#f59e0b", // amber rtasks: "#3b82f6", // blue rfiles: "#10b981", // emerald rsplat: "#818cf8", // indigo rphotos: "#ec4899", // pink rbooks: "#f97316", // orange rforum: "#8b5cf6", // violet rinbox: "#06b6d4", // cyan rvote: "#ef4444", // red rtube: "#a855f7", // purple }; const TOUCH_DRAG_THRESHOLD = 8; // px before a touch becomes a drag const TOUCH_DRAG_LONGPRESS_MS = 350; /** * Make an element draggable with the rspace-item protocol. * Adds draggable attribute and dragstart handler for mouse, plus a * pointer-based touch/pen fallback that fires synthetic DragEvents. */ export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) { el.draggable = true; el.style.cursor = "grab"; // Native HTML5 drag (mouse / desktop trackpad) el.addEventListener("dragstart", (e) => { if (!e.dataTransfer) return; e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload)); e.dataTransfer.setData("text/plain", payload.title); e.dataTransfer.effectAllowed = "copyMove"; el.style.opacity = "0.6"; }); el.addEventListener("dragend", () => { el.style.opacity = ""; }); // Pointer-based fallback for touch + pen (iOS/iPadOS/Android) attachPointerDragFallback(el, payload); } /** * Attach drag handlers to all matching elements within a root. * `selector` matches the card elements. * `payloadFn` extracts the payload from each matched element. */ export function makeDraggableAll( root: Element | ShadowRoot, selector: string, payloadFn: (el: HTMLElement) => RSpaceItemPayload | null, ) { root.querySelectorAll(selector).forEach((el) => { const payload = payloadFn(el); if (payload) makeDraggable(el, payload); }); } // ── Pointer-based drag fallback ────────────────────────────────────── interface TouchDragState { el: HTMLElement; payload: RSpaceItemPayload; startX: number; startY: number; started: boolean; pointerId: number; dataTransfer: DataTransfer | null; ghost: HTMLElement | null; lastTarget: Element | null; longPressTimer: ReturnType | null; } function attachPointerDragFallback(el: HTMLElement, payload: RSpaceItemPayload) { let state: TouchDragState | null = null; el.addEventListener("pointerdown", (e) => { // Mouse goes through native HTML5 drag path — skip. if (e.pointerType === "mouse") return; // Only primary button if (e.button !== 0) return; // If inside an editable surface, don't hijack const target = e.target as HTMLElement | null; if (target?.closest("input, textarea, [contenteditable='true']")) return; state = { el, payload, startX: e.clientX, startY: e.clientY, started: false, pointerId: e.pointerId, dataTransfer: null, ghost: null, lastTarget: null, longPressTimer: null, }; // Long-press alternative: start drag after 350ms even without movement state.longPressTimer = setTimeout(() => { if (state && !state.started) beginPointerDrag(state, e.clientX, e.clientY); }, TOUCH_DRAG_LONGPRESS_MS); }); window.addEventListener("pointermove", (e) => { if (!state || e.pointerId !== state.pointerId) return; if (!state.started) { const dx = e.clientX - state.startX; const dy = e.clientY - state.startY; if (Math.hypot(dx, dy) < TOUCH_DRAG_THRESHOLD) return; beginPointerDrag(state, e.clientX, e.clientY); } if (!state.started) return; // Prevent scrolling while dragging e.preventDefault(); // Move ghost if (state.ghost) { state.ghost.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`; } // Find current drop target (hide ghost briefly so it doesn't block elementFromPoint) const ghost = state.ghost; if (ghost) ghost.style.display = "none"; const under = document.elementFromPoint(e.clientX, e.clientY); if (ghost) ghost.style.display = ""; if (under !== state.lastTarget) { // dragleave on previous if (state.lastTarget && state.dataTransfer) { dispatchDragEvent(state.lastTarget, "dragleave", state.dataTransfer, e.clientX, e.clientY); } state.lastTarget = under; if (under && state.dataTransfer) { dispatchDragEvent(under, "dragenter", state.dataTransfer, e.clientX, e.clientY); } } if (state.lastTarget && state.dataTransfer) { dispatchDragEvent(state.lastTarget, "dragover", state.dataTransfer, e.clientX, e.clientY); } }, { passive: false }); const finish = (e: PointerEvent) => { if (!state || e.pointerId !== state.pointerId) return; if (state.longPressTimer) clearTimeout(state.longPressTimer); if (state.started) { // Dispatch drop on current target if (state.lastTarget && state.dataTransfer) { dispatchDragEvent(state.lastTarget, "drop", state.dataTransfer, e.clientX, e.clientY); } // Dispatch dragend on source if (state.dataTransfer) { dispatchDragEvent(state.el, "dragend", state.dataTransfer, e.clientX, e.clientY); } state.el.style.opacity = ""; if (state.ghost) state.ghost.remove(); } state = null; }; window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", finish); } function beginPointerDrag(state: TouchDragState, x: number, y: number) { state.started = true; state.el.style.opacity = "0.4"; // Build a real DataTransfer so existing dragover/drop listeners that // call `e.dataTransfer.getData('application/rspace-item')` work unchanged. let dt: DataTransfer; try { dt = new DataTransfer(); dt.setData("application/rspace-item", JSON.stringify(state.payload)); dt.setData("text/plain", state.payload.title); dt.effectAllowed = "copyMove"; } catch { // Older Safari fallback — we'll still dispatch events, but dataTransfer.getData // may not work. Consumers can read from (event as any).rspaceItemPayload. dt = new DataTransfer(); } state.dataTransfer = dt; // Let source's existing `dragstart` listeners set extra payloads // (e.g. rdocs `application/x-rdocs-move`). dispatchDragEvent(state.el, "dragstart", dt, x, y); // Build drag ghost (floating preview of the source element) const ghost = document.createElement("div"); ghost.textContent = state.payload.title; ghost.style.cssText = ` position: fixed; top: 0; left: 0; transform: translate(${x}px, ${y}px); z-index: 100000; pointer-events: none; padding: 8px 12px; background: ${state.payload.color || "#3b82f6"}; color: #fff; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 4px 16px rgba(0,0,0,0.25); opacity: 0.92; max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; document.body.appendChild(ghost); state.ghost = ghost; } function dispatchDragEvent( target: Element | EventTarget, type: string, dataTransfer: DataTransfer, clientX: number, clientY: number, ): void { let ev: DragEvent; try { ev = new DragEvent(type, { bubbles: true, cancelable: true, clientX, clientY, dataTransfer, }); } catch { // Older browsers: fall back to CustomEvent with dataTransfer attached const fallback = new Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(fallback, "dataTransfer", { value: dataTransfer, enumerable: true }); Object.defineProperty(fallback, "clientX", { value: clientX, enumerable: true }); Object.defineProperty(fallback, "clientY", { value: clientY, enumerable: true }); target.dispatchEvent(fallback); return; } target.dispatchEvent(ev); }