Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { useRef, useState } from "react";
// Elements where mousedown should start an interaction, not a widget reorder.
// Anchors are excluded so clicking still navigates, and dragging an anchor
// drags the widget.
const INTERACTIVE_DESCENDANT_SELECTOR = [
"button",
"moz-button",
"moz-checkbox",
"moz-toggle",
"moz-radio",
"moz-select",
"moz-input-text",
"moz-input-password",
"moz-input-search",
"input",
"textarea",
"select",
"[contenteditable='true']",
"[role='button']",
"[role='checkbox']",
"[role='switch']",
"[role='textbox']",
].join(", ");
/**
* Builds a high-DPI drag image clone anchored at the cursor's grab point.
* Mounting under el.parentElement keeps the CSS cascade applied. scale(1/dpr)
* compensates for Firefox's setDragImage handling at non-1 device pixel ratios.
*/
function setupDragImage(e, el) {
const rect = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = Math.round(rect.width);
const cssHeight = Math.round(rect.height);
const clone = el.cloneNode(true);
// position:fixed keeps the clone out of grid flow; -9999px hides it until
// setDragImage captures it.
clone.style.cssText = `position:fixed;top:-9999px;left:-9999px;width:${cssWidth}px;height:${cssHeight}px;transform:scale(${1 / dpr});transform-origin:top left;pointer-events:none;margin:0`;
const mountPoint = el.parentElement || document.body;
mountPoint.appendChild(clone);
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
e.dataTransfer.setDragImage(
clone,
Math.round(offsetX * (1 / dpr)),
Math.round(offsetY * (1 / dpr))
);
requestAnimationFrame(() => clone.remove());
}
/**
* Captures every slot's bounding rect at drag start, in effectiveOrder order.
* Rects stay frozen for the whole drag, so cursor (x, y) always maps to the
* same slot index regardless of how the live preview reflows.
*/
function captureSlotRects(sourceEl, effectiveOrder) {
const container = sourceEl.parentElement;
if (!container) {
return null;
}
// data-widget-id is on the slot wrapper, not the inner article.
const slots = [...container.querySelectorAll("[data-widget-id]")];
const rectsById = Object.fromEntries(
slots.map(el => [el.dataset.widgetId, el.getBoundingClientRect()])
);
return effectiveOrder.map(id => rectsById[id] || null);
}
/**
* Returns the slot whose rect contains the cursor, or null if the cursor is
* in a gap. Null means "no change" so wandering through gaps doesn't snap to
* the nearest slot. Exported for unit tests.
*/
export function cursorToSlot(slotRects, clientX, clientY) {
if (!slotRects) {
return null;
}
for (let i = 0; i < slotRects.length; i++) {
const rect = slotRects[i];
if (
rect &&
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
return i;
}
}
return null;
}
/**
* Mouse-driven widget reorder. Emits a previewOrder for the composer and
* commits via commitOrder.
*/
export function useMouseDnD({ effectiveOrder, commitOrder }) {
const [draggedId, setDraggedId] = useState(null);
// Where the source will land if drop fires. Driven only by cursor position
// against captured slot rects, no history or hysteresis.
const [targetSlot, setTargetSlot] = useState(null);
// dragstart's e.target is the drag source, not the actual mousedowned
// element. We track mousedown separately for the interactive-descendant
// guard.
const mouseDownTargetRef = useRef(null);
const slotRectsRef = useRef(null);
let previewOrder = null;
if (draggedId !== null && targetSlot !== null) {
const next = effectiveOrder.filter(id => id !== draggedId);
next.splice(targetSlot, 0, draggedId);
previewOrder = next;
}
function handleMouseDown(e) {
mouseDownTargetRef.current = e.target;
}
function handleDragStart(e, id) {
// If the drag started on a nested draggable, let it own the drag.
const closestDraggable = e.target.closest("[draggable='true']");
if (closestDraggable && closestDraggable !== e.currentTarget) {
mouseDownTargetRef.current = null;
return;
}
// If mousedown was on an interactive element, abort so a hand twitch
// between mousedown and mouseup doesn't lose the user's click.
const mouseDownTarget = mouseDownTargetRef.current;
mouseDownTargetRef.current = null;
if (
mouseDownTarget &&
mouseDownTarget.closest(INTERACTIVE_DESCENDANT_SELECTOR)
) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/widget-id", id);
// Capture rects BEFORE setupDragImage. The clone also has data-widget-id,
// so capturing after would overwrite source's rect with -9999,-9999.
slotRectsRef.current = captureSlotRects(e.currentTarget, effectiveOrder);
setupDragImage(e, e.currentTarget);
setDraggedId(id);
setTargetSlot(effectiveOrder.indexOf(id));
}
function handleDragOver(e) {
// Only handle widget reorders; let other drags reach their handlers.
if (!e.dataTransfer.types.includes("text/widget-id")) {
return;
}
e.preventDefault();
// Stop propagation so inner DnD (e.g. moz-reorderable-list inside Lists)
// doesn't also handle this event.
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
if (!draggedId) {
return;
}
const slot = cursorToSlot(slotRectsRef.current, e.clientX, e.clientY);
if (slot !== null && slot !== targetSlot) {
setTargetSlot(slot);
}
}
function cleanup() {
setDraggedId(null);
setTargetSlot(null);
slotRectsRef.current = null;
}
// Commit on `drop`, not `dragend`, so Escape, release-outside, and
// release-in-gap all cancel (no `drop` fires for those). A window keydown
// listener for Escape is unreliable because the browser owns the keyboard
// during a native HTML5 drag.
function handleDrop(e) {
if (!e.dataTransfer.types.includes("text/widget-id")) {
return;
}
e.preventDefault();
e.stopPropagation();
if (draggedId !== null && targetSlot !== null) {
const sourceIdx = effectiveOrder.indexOf(draggedId);
if (sourceIdx !== -1 && targetSlot !== sourceIdx) {
const next = effectiveOrder.filter(id => id !== draggedId);
next.splice(targetSlot, 0, draggedId);
commitOrder(next);
}
}
}
function handleDragEnd() {
cleanup();
}
return {
draggedId,
previewOrder,
handleMouseDown,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
};
}