Files
ontime/apps/client/src/features/rundown/hooks/useRundownDnd.ts
T
Claude 23d1ebeac1 fix(rundown): keep dragged entry visible across long drags
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
2026-09-16 18:39:58 +00:00

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],
);
}