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 { useCallback, useLayoutEffect, useRef, useState } from "react";
// Classic TopSites drag-and-drop — pure mechanics over a caller-built list.
// Movability rules, the persisted action, and telemetry are all injected, so
// nothing domain- or store-shaped lives in here.
export function useTopSitesDnD({
baseSites,
rows,
isMovable, // site flows into the free pool
isShiftable, // anchored site that slides to make room
onDragStart,
onReorder, // ({ site, title, fromIndex, toIndex }) — caller persists it
pinInPlace, // let a movable tile commit at its own slot (grouped pins only)
}) {
const [draggedIndex, setDraggedIndex] = useState(null);
const [draggedSite, setDraggedSite] = useState(null);
const [draggedTitle, setDraggedTitle] = useState(null);
const [previewSites, setPreviewSites] = useState(null);
// Set on drop so the trailing dragend doesn't reset us.
const droppedRef = useRef(false);
const resetDrag = useCallback(() => {
setDraggedIndex(null);
setDraggedSite(null);
setDraggedTitle(null);
setPreviewSites(null);
}, []);
// The list as it'd look after dropping at `index`: anchors hold their spot
// (shiftable ones slide to make room), movable tiles refill the holes.
const makeTopSitesPreview = useCallback(
index => {
const sites = baseSites.slice(); // shared — don't mutate in place
sites[draggedIndex] = null;
const preview = sites.map(site =>
site && !isMovable(site) ? site : null
);
const pool = sites.filter(site => site && isMovable(site));
const siteToInsert = Object.assign({}, draggedSite, {
isPinned: true,
isDragged: true,
});
if (!preview[index]) {
preview[index] = siteToInsert;
} else {
// Shift anchors toward the hole the dragged tile left.
let holeIndex = index;
const indexStep = index > draggedIndex ? -1 : 1;
while (preview[holeIndex]) {
holeIndex += indexStep;
}
const shiftingStep = index > draggedIndex ? 1 : -1;
while (index > draggedIndex ? holeIndex < index : holeIndex > index) {
let nextIndex = holeIndex + shiftingStep;
// Skip fixed anchors — they don't move.
while (
preview[nextIndex] &&
!isMovable(preview[nextIndex]) &&
!isShiftable(preview[nextIndex])
) {
nextIndex += shiftingStep;
}
preview[holeIndex] = preview[nextIndex];
holeIndex = nextIndex;
}
preview[index] = siteToInsert;
}
for (let i = 0; i < preview.length; i++) {
if (!preview[i]) {
preview[i] = pool.shift() || null;
}
}
return preview;
},
[baseSites, draggedIndex, draggedSite, isMovable, isShiftable]
);
const onDragEvent = useCallback(
(event, index, link, title) => {
switch (event.type) {
case "dragstart":
droppedRef.current = false;
setDraggedIndex(index);
setDraggedSite(link);
setDraggedTitle(title);
onDragStart?.(index);
break;
case "dragend":
if (!droppedRef.current) {
resetDrag();
}
break;
case "dragenter":
// Dropping on the dragged tile's own slot is a no-op when reordering,
// but with pinInPlace a movable tile dropped there still gets pinned,
// so preview the reflow instead of clearing it.
if (
index === draggedIndex &&
!(pinInPlace && isMovable(draggedSite))
) {
setPreviewSites(null);
} else {
setPreviewSites(makeTopSitesPreview(index));
}
break;
case "dragover":
// List-level signal (no index) that the cursor left the pinned drop
// region. Tiles pass their index, so a tile's own dragover is ignored.
if (index === undefined) {
setPreviewSites(null);
}
break;
case "dragleave":
// Safety net for leaving the grid entirely off an edge tile, where no
// bare-grid dragover lands first.
setPreviewSites(null);
break;
case "drop":
if (
index !== draggedIndex ||
(pinInPlace && isMovable(draggedSite))
) {
droppedRef.current = true;
onReorder?.({
site: draggedSite,
title: draggedTitle,
fromIndex: draggedIndex,
toIndex: index,
});
}
break;
}
},
[
draggedIndex,
draggedSite,
draggedTitle,
isMovable,
pinInPlace,
makeTopSitesPreview,
onDragStart,
onReorder,
resetDrag,
]
);
// Clear the drag once the committed order arrives. Usually the dragged url has
// left its old slot; a pin-in-place drop commits at the same slot, so also
// clear when a drop happened and new rows arrived. Layout effect so the
// preview doesn't flash first.
const prevRowsRef = useRef(rows);
useLayoutEffect(() => {
const prevRows = prevRowsRef.current;
const movedFromSlot =
prevRows?.[draggedIndex]?.url === draggedSite?.url &&
rows[draggedIndex]?.url !== draggedSite?.url;
const committedInPlace =
droppedRef.current && prevRows && prevRows !== rows;
if (draggedSite && (movedFromSlot || committedInPlace)) {
resetDrag();
}
prevRowsRef.current = rows;
}, [rows, draggedSite, draggedIndex, resetDrag]);
return { previewSites, onDragEvent, draggedSite };
}