264 lines
8.2 KiB
TypeScript
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);
|
|
}
|