feat(playlists): drag rows onto a list in the rail
A row's grip carries it, or every selected row with a selected one, onto another list, under a sort or a filter too, and the toast undoes. The rail and the page are separate React trees, so the page reads the rail by what lies under the pointer. An unpinned rail peeks by the drag's own hover and scrolls at its edges. Many rows dropped inside the list stay; a key still moves its own row.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -19,9 +20,13 @@ import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
DragOverlay,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
@@ -29,7 +34,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CSS, getEventCoordinates } from "@dnd-kit/utilities";
|
||||
import { Check, Eraser, FolderInput, History, ListPlus, Play, X } from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import {
|
||||
@@ -73,7 +78,7 @@ import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./playlistsContext";
|
||||
import { railDefaultId } from "./playlistsInit";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { followRemoval, navItemAttrs } from "../../lib/listNav";
|
||||
import { ColumnHead } from "../../components/ui/tableHeader";
|
||||
import { alignClass } from "../../components/ui/tableAlign";
|
||||
import {
|
||||
@@ -86,6 +91,7 @@ import {
|
||||
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
|
||||
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
||||
import { DragHandle } from "../../components/ui/DragHandle";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { RowSelectBox } from "../../components/ui/RowSelectBox";
|
||||
import { useOrderItems } from "../../components/ui/useOrderItems";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
@@ -98,6 +104,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import PlaylistsHeader from "./PlaylistsHeader";
|
||||
import { SelectionBand } from "./SelectionBand";
|
||||
import { isOverRail, railDropTarget, startRailDrag, stopRailDrag } from "./railDrop";
|
||||
import clsx from "clsx";
|
||||
|
||||
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
|
||||
@@ -129,12 +136,39 @@ const moveTargetIcon = (pl: Playlist) =>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
);
|
||||
|
||||
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
|
||||
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
|
||||
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
|
||||
const at = args.pointerCoordinates;
|
||||
if (!at) return args;
|
||||
const { width, height } = args.collisionRect;
|
||||
const left = at.x - width / 2;
|
||||
const top = at.y - height / 2;
|
||||
return {
|
||||
...args,
|
||||
collisionRect: { width, height, left, top, right: left + width, bottom: top + height },
|
||||
};
|
||||
}
|
||||
|
||||
/** The drag image hangs below the pointer: on it, it covered the name of the list it was over. */
|
||||
const beneathPointer: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => {
|
||||
const at = activatorEvent && getEventCoordinates(activatorEvent);
|
||||
if (!at || !draggingNodeRect) return transform;
|
||||
return {
|
||||
...transform,
|
||||
x: transform.x + at.x - draggingNodeRect.left + 12,
|
||||
y: transform.y + at.y - draggingNodeRect.top + 16,
|
||||
};
|
||||
};
|
||||
|
||||
function Row({
|
||||
video,
|
||||
index,
|
||||
columns,
|
||||
selected,
|
||||
carried,
|
||||
dragDisabled,
|
||||
listDragOnly,
|
||||
dragHint,
|
||||
menuProps,
|
||||
}: {
|
||||
@@ -143,7 +177,11 @@ function Row({
|
||||
index: number;
|
||||
columns: Column<Video>[];
|
||||
selected: boolean;
|
||||
/** Going along with the row being dragged. */
|
||||
carried: boolean;
|
||||
dragDisabled: boolean;
|
||||
/** A drag can only leave for another list: the view holds the order, and no key reaches the rail. */
|
||||
listDragOnly: boolean;
|
||||
dragHint: string;
|
||||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||||
@@ -163,7 +201,7 @@ function Row({
|
||||
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
|
||||
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
|
||||
transition: isDragging ? undefined : transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
opacity: isDragging || carried ? 0.6 : 1,
|
||||
}}
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
@@ -181,13 +219,20 @@ function Row({
|
||||
>
|
||||
{col.key === GRIP_KEY ? (
|
||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
|
||||
// put and can explain itself instead of silently doing nothing.
|
||||
// attributes/listeners. Disabled (not hidden) with nowhere to go, so the affordance
|
||||
// stays put and can explain itself instead of silently doing nothing.
|
||||
<DragHandle
|
||||
{...attributes}
|
||||
aria-describedby={listDragOnly ? undefined : attributes["aria-describedby"]}
|
||||
{...(dragDisabled ? {} : listeners)}
|
||||
unavailable={dragDisabled}
|
||||
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
|
||||
label={
|
||||
dragDisabled
|
||||
? dragHint
|
||||
: t(listDragOnly ? "playlists.dragHandleToList" : "playlists.dragHandleOf", {
|
||||
title: video.title,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
col.render(video)
|
||||
@@ -522,6 +567,8 @@ function PlaylistDetail() {
|
||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
// Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||
const pointerSensorOnly = sensors.slice(0, 1);
|
||||
|
||||
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
||||
const dndA11y = useSortableA11y((id) => {
|
||||
@@ -540,8 +587,53 @@ function PlaylistDetail() {
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
}
|
||||
|
||||
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
|
||||
const [carried, setCarried] = useState<readonly string[] | null>(null);
|
||||
const carriedRef = useRef<readonly string[]>([]);
|
||||
const droppedOnRef = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopRailDrag();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function onDragStart(e: DragStartEvent) {
|
||||
const id = String(e.active.id);
|
||||
const from = "clientX" in e.activatorEvent ? (e.activatorEvent as PointerEvent) : null;
|
||||
const rows =
|
||||
from && selection.isSelected(id) && selection.shownSelected.length > 1
|
||||
? selection.shownSelected
|
||||
: [id];
|
||||
carriedRef.current = rows;
|
||||
droppedOnRef.current = null;
|
||||
// No drag image for a key: it leaves nothing to carry, and the row itself walks the list.
|
||||
if (from) {
|
||||
setCarried(rows);
|
||||
startRailDrag(from.clientX, from.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragCancel() {
|
||||
stopRailDrag();
|
||||
setCarried(null);
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active: a, over } = e;
|
||||
const droppedOn = stopRailDrag();
|
||||
setCarried(null);
|
||||
droppedOnRef.current = droppedOn;
|
||||
if (droppedOn != null) {
|
||||
const target = moveTargets.find((p) => p.id === droppedOn);
|
||||
const row = shown.find((v) => v.id === a.id);
|
||||
if (!target || !row) return;
|
||||
if (e.activatorEvent.target instanceof Element) followRemoval(e.activatorEvent.target);
|
||||
if (selection.isSelected(row.id)) moveSelected(target);
|
||||
else void moveRows([row], target);
|
||||
return;
|
||||
}
|
||||
// A collision reports nothing under a view or for many rows, so nothing is reordered there.
|
||||
if (!over || a.id === over.id || selectedId == null) return;
|
||||
const oldIndex = items.findIndex((v) => v.id === a.id);
|
||||
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||
@@ -948,6 +1040,38 @@ function PlaylistDetail() {
|
||||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
// While the rows must stay put the others make no way: no row is "over" anything.
|
||||
const dragCollision: CollisionDetection = (args) =>
|
||||
storedOrderEditable && carriedRef.current.length <= 1 && !isOverRail()
|
||||
? closestCenter(pointerFirst(args))
|
||||
: [];
|
||||
const listNameOf = useLatest((id: number | null) => {
|
||||
const list = moveTargets.find((p) => p.id === id);
|
||||
return list ? plName(list) : "";
|
||||
});
|
||||
const dragA11y = useMemo(() => {
|
||||
const base = dndA11y.announcements;
|
||||
return {
|
||||
...dndA11y,
|
||||
announcements: {
|
||||
...base,
|
||||
onDragStart: (args: Parameters<typeof base.onDragStart>[0]) =>
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
||||
: base.onDragStart(args),
|
||||
onDragMove: () => {
|
||||
const id = railDropTarget();
|
||||
return id == null
|
||||
? undefined
|
||||
: t("playlists.dragOverList", { name: listNameOf.current(id) });
|
||||
},
|
||||
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
||||
droppedOnRef.current == null
|
||||
? base.onDragEnd(args)
|
||||
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
||||
},
|
||||
};
|
||||
}, [dndA11y, t, listNameOf]);
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
? []
|
||||
@@ -1353,12 +1477,16 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
sensors={viewActive ? pointerSensorOnly : sensors}
|
||||
collisionDetection={dragCollision}
|
||||
// Not the list, under a drag that is over the rail: it would run away beside it.
|
||||
autoScroll={{ canScroll: () => !isOverRail() }}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={dndA11y}
|
||||
accessibility={dragA11y}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. A container,
|
||||
so a narrow column hides by the table's width, not the window's (`narrowHidden`). */}
|
||||
@@ -1426,7 +1554,9 @@ function PlaylistDetail() {
|
||||
video={v}
|
||||
columns={tableColumns}
|
||||
selected={selection.isSelected(v.id)}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
carried={!!carried && carried.includes(v.id)}
|
||||
dragDisabled={!canEdit || (viewActive && !moveTargets.length)}
|
||||
listDragOnly={viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
@@ -1440,6 +1570,28 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
|
||||
Above every layer (the tooltip's level), and no way back to fly: it drops or stays. Out
|
||||
of the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||
{createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={null}
|
||||
zIndex={100}
|
||||
className="pointer-events-none"
|
||||
modifiers={[beneathPointer]}
|
||||
>
|
||||
{carried && (
|
||||
<div
|
||||
className={`${SURFACE.popover} w-fit max-w-xs truncate px-3 py-2 text-sm font-medium`}
|
||||
>
|
||||
{carried.length > 1
|
||||
? t("playlists.itemCount", { count: carried.length })
|
||||
: shown.find((v) => v.id === carried[0])?.title}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user