rspace-online/shared/draggable.ts

264 lines
8.2 KiB
TypeScript

/**
* 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<string, string> = {
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<HTMLElement>(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<typeof setTimeout> | 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);
}