mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-17 03:53:06 +00:00
23d1ebeac1
The rundown list is virtualised, so dragging an entry far enough for its row to leave the render window unmounts the draggable. dnd-kit then loses both the rect used for collision detection and the data attached to the active element: the dragged element stopped being rendered and the drop had no effect. - render a drag overlay (portaled to the body) with a lightweight preview of the dragged entry, which keeps the drag visible and gives dnd-kit a stable rect for collision detection - keep a copy of the active element data from drag start, and use it on drag end when dnd-kit falls back to empty data - sortable entries stay in place as a dimmed placeholder while dragging, since the overlay now represents the moving element - clear the drag state on drag cancel Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEnUVARPnSqXijiz8KiLiE
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
import {
|
|
type Data,
|
|
DragEndEvent,
|
|
DragOverEvent,
|
|
DragStartEvent,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { type EntryId, type Rundown, SupportedEntry, isOntimeGroup } from 'ontime-types';
|
|
import { reorderArray } from 'ontime-utils';
|
|
import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react';
|
|
|
|
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
|
import { canDrop } from '../rundown.utils';
|
|
|
|
interface UseRundownDndOptions {
|
|
entries: Rundown['entries'];
|
|
sortableData: EntryId[];
|
|
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
|
|
getIsCollapsed: (groupId: EntryId) => boolean;
|
|
handleCollapseGroup: (collapsed: boolean, groupId: EntryId | undefined) => void;
|
|
entryActions: ReturnType<typeof useEntryActions>;
|
|
}
|
|
|
|
export function useRundownDnd({
|
|
entries,
|
|
sortableData,
|
|
setSortableData,
|
|
getIsCollapsed,
|
|
handleCollapseGroup,
|
|
entryActions,
|
|
}: UseRundownDndOptions) {
|
|
const { reorderEntry } = entryActions;
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
|
const isDraggingRef = useRef(false);
|
|
|
|
/**
|
|
* The rundown list is virtualised, which means that the dragged element can be unmounted
|
|
* if the user drags it far enough for it to leave the render window.
|
|
* When that happens, dnd-kit loses the data associated with the active element,
|
|
* so we keep our own copy from the moment the drag started.
|
|
*/
|
|
const activeDataRef = useRef<Data | null>(null);
|
|
const [activeId, setActiveId] = useState<EntryId | null>(null);
|
|
|
|
const clearActive = useCallback(() => {
|
|
isDraggingRef.current = false;
|
|
activeDataRef.current = null;
|
|
setActiveId(null);
|
|
}, []);
|
|
|
|
/**
|
|
* On drag end, we reorder the events
|
|
*/
|
|
const handleOnDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
// if the dragged element was unmounted by the virtualiser, dnd-kit gives us empty data
|
|
// in which case we fallback to the snapshot taken on drag start
|
|
const activeData = active.data.current?.sortable ? active.data.current : activeDataRef.current;
|
|
clearActive();
|
|
|
|
if (!over?.id || active.id === over.id) {
|
|
return;
|
|
}
|
|
|
|
if (!activeData?.sortable || !over.data.current) {
|
|
return;
|
|
}
|
|
|
|
const fromIndex: number = activeData.sortable.index;
|
|
const toIndex: number = over.data.current.sortable.index;
|
|
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
|
|
|
let destinationId = over.id as EntryId;
|
|
const isDraggingGroup = activeData.type === SupportedEntry.Group;
|
|
|
|
// prevent dropping a group inside another
|
|
if (
|
|
isDraggingGroup &&
|
|
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* We need to specially handle the end-group
|
|
* Dragging before a end-group will add the entry to the end of the group
|
|
* Dragging after a end-group will add the event after the group itself
|
|
* Dragging to the top of a group either place before first entry or if no entries do insert
|
|
*/
|
|
if (destinationId.startsWith('end-')) {
|
|
destinationId = destinationId.replace('end-', '');
|
|
// if we are moving before the end, we use the insert operation
|
|
if (placement === 'before') {
|
|
placement = 'insert';
|
|
}
|
|
} else {
|
|
const group = entries[destinationId];
|
|
// if dragging into a group
|
|
if (isOntimeGroup(group) && placement === 'after') {
|
|
if (isDraggingGroup) {
|
|
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
|
|
// so we can safely push the dragged event after the group
|
|
destinationId = group.id;
|
|
} else if (group.entries.length === 0) {
|
|
// ... and the group is entry, we insert
|
|
destinationId = group.id;
|
|
placement = 'insert';
|
|
} else {
|
|
// otherwise we add it to before the first group child
|
|
destinationId = group.entries[0];
|
|
placement = 'before';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optimistic update pattern to keep DND responsive
|
|
// 1. Keep copy of current state in case we need to revert
|
|
const currentEntries = [...sortableData];
|
|
// 2. Immediately update local state for responsive UI
|
|
setSortableData((currentEntries) => {
|
|
return reorderArray(currentEntries, fromIndex, toIndex);
|
|
});
|
|
// 3. Trigger async mutation, revert on error
|
|
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
|
setSortableData(currentEntries);
|
|
});
|
|
},
|
|
[entries, sortableData, setSortableData, getIsCollapsed, reorderEntry],
|
|
);
|
|
|
|
/**
|
|
* On drag start we keep a reference to the dragged element
|
|
* and, if we are dragging a group, we force collapse it
|
|
* This avoids strange scenarios like dropping a group inside itself
|
|
*/
|
|
const handleOnDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
isDraggingRef.current = true;
|
|
activeDataRef.current = event.active.data.current ?? null;
|
|
setActiveId(event.active.id as EntryId);
|
|
|
|
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
|
if (isGroup) {
|
|
handleCollapseGroup(true, event.active.id as EntryId);
|
|
}
|
|
},
|
|
[handleCollapseGroup],
|
|
);
|
|
|
|
/**
|
|
* On drag cancel we discard any reference to the dragged element
|
|
*/
|
|
const handleOnDragCancel = useCallback(() => {
|
|
clearActive();
|
|
}, [clearActive]);
|
|
|
|
/**
|
|
* When we drag over a group, we expand it if it is collapsed
|
|
*/
|
|
const expandOverGroup = useCallback(
|
|
(event: DragOverEvent) => {
|
|
// if we are dragging a group, the drop operation is invalid so we dont expand
|
|
if (event.active.data.current?.type === SupportedEntry.Group) {
|
|
return;
|
|
}
|
|
if (event.over?.data.current?.type !== SupportedEntry.Group) {
|
|
return;
|
|
}
|
|
|
|
const groupId = event.over?.id as EntryId | undefined;
|
|
handleCollapseGroup(false, groupId);
|
|
},
|
|
[handleCollapseGroup],
|
|
);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
sensors,
|
|
isDraggingRef,
|
|
activeId,
|
|
handleOnDragEnd,
|
|
handleOnDragStart,
|
|
handleOnDragCancel,
|
|
expandOverGroup,
|
|
}),
|
|
[sensors, activeId, handleOnDragEnd, handleOnDragStart, handleOnDragCancel, expandOverGroup],
|
|
);
|
|
}
|