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
This commit is contained in:
Claude
2026-09-16 18:39:58 +00:00
parent 6e02d3a985
commit 23d1ebeac1
8 changed files with 172 additions and 16 deletions
+22 -2
View File
@@ -1,4 +1,4 @@
import { DndContext, closestCenter } from '@dnd-kit/core';
import { DndContext, DragOverlay, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import {
type EntryId,
@@ -9,6 +9,7 @@ import {
isOntimeGroup,
} from 'ontime-types';
import { Fragment, type HTMLProps, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
@@ -22,6 +23,7 @@ import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import { useRundownCommands } from './hooks/useRundownCommands';
import { useRundownDnd } from './hooks/useRundownDnd';
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
import RundownDragPreview from './rundown-drag-preview/RundownDragPreview';
import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
@@ -209,6 +211,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// gather presentation options
const isEditMode = editorMode === AppMode.Edit;
// entry being dragged, used to render the drag overlay
const draggedEntry = dnd.activeId ? entries[dnd.activeId] : undefined;
// gather rundown wide data
const lastEntryId = order.at(-1);
@@ -325,7 +330,8 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext
onDragEnd={dnd.handleOnDragEnd}
onDragStart={dnd.collapseDraggedGroups}
onDragStart={dnd.handleOnDragStart}
onDragCancel={dnd.handleOnDragCancel}
onDragOver={dnd.expandOverGroup}
sensors={dnd.sensors}
collisionDetection={closestCenter}
@@ -357,6 +363,20 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
}}
/>
</SortableContext>
{/**
* The drag overlay is rendered outside the virtualised list
* ensuring that the user sees the dragged element even after
* the original element is unmounted by the virtualiser
* It is portaled to the body to avoid being clipped by the rundown layout
*/}
{createPortal(
<DragOverlay dropAnimation={null}>
{draggedEntry && (
<RundownDragPreview entry={draggedEntry} eventIndex={metadata[draggedEntry.id]?.eventIndex} />
)}
</DragOverlay>,
document.body,
)}
</DndContext>
</div>
);
@@ -1,7 +1,15 @@
import { DragEndEvent, DragOverEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
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 } from 'react';
import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { canDrop } from '../rundown.utils';
@@ -27,28 +35,46 @@ export function useRundownDnd({
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;
isDraggingRef.current = false;
// 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 (!active.data.current || !over.data.current) {
if (!activeData?.sortable || !over.data.current) {
return;
}
const fromIndex: number = active.data.current.sortable.index;
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 = active.data.current?.type === SupportedEntry.Group;
const isDraggingGroup = activeData.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
@@ -106,12 +132,16 @@ export function useRundownDnd({
);
/**
* When we drag a group, we force collapse it
* 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 collapseDraggedGroups = useCallback(
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);
@@ -120,6 +150,13 @@ export function useRundownDnd({
[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
*/
@@ -143,10 +180,12 @@ export function useRundownDnd({
() => ({
sensors,
isDraggingRef,
activeId,
handleOnDragEnd,
collapseDraggedGroups,
handleOnDragStart,
handleOnDragCancel,
expandOverGroup,
}),
[sensors, handleOnDragEnd, collapseDraggedGroups, expandOverGroup],
[sensors, activeId, handleOnDragEnd, handleOnDragStart, handleOnDragCancel, expandOverGroup],
);
}
@@ -41,7 +41,10 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
// while dragging, the element is represented by the drag overlay
// we keep the original element in place as a placeholder
transform: isDragging ? undefined : CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : undefined,
transition,
};
@@ -0,0 +1,30 @@
@use '../blockMixins' as *;
.preview {
@include block-styling;
display: grid;
grid-template-columns: $block-binder-width 1fr;
align-items: center;
height: $secondary-block-height;
background-color: $block-bg;
box-shadow: $block-box-shadow;
cursor: grabbing;
opacity: 0.9;
}
.binder {
height: 100%;
display: grid;
place-content: center;
font-size: 1rem;
background-color: var(--user-bg, $gray-1050);
color: $section-white;
}
.label {
padding-inline: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,55 @@
import { type OntimeEntry, isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { IoReorderTwo } from 'react-icons/io5';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from './RundownDragPreview.module.scss';
interface RundownDragPreviewProps {
entry: OntimeEntry;
eventIndex?: number;
}
/**
* Lightweight representation of an entry to be shown in the drag overlay
* ------------------------------------
* The rundown list is virtualised, which means that the dragged element
* is unmounted once it leaves the render window.
* Rendering the drag overlay guarantees that the user
* always sees the element being dragged, regardless of the scroll position.
*/
export default function RundownDragPreview({ entry, eventIndex }: RundownDragPreviewProps) {
const colour = isOntimeDelay(entry) ? '' : entry.colour;
const binderColours = colour ? getAccessibleColour(colour) : undefined;
return (
<div className={style.preview} style={{ '--user-bg': colour || '#929292' }}>
<div className={style.binder} style={binderColours}>
<IoReorderTwo />
</div>
<div className={style.label}>{getPreviewLabel(entry, eventIndex)}</div>
</div>
);
}
function getPreviewLabel(entry: OntimeEntry, eventIndex?: number): string {
if (isOntimeGroup(entry)) {
return entry.title || 'Untitled group';
}
if (isOntimeEvent(entry)) {
const prefix = eventIndex !== undefined ? `${eventIndex}` : entry.cue;
return `${prefix} ${entry.title || 'Untitled'}`.trim();
}
if (isOntimeMilestone(entry)) {
return entry.title || 'Untitled milestone';
}
if (isOntimeDelay(entry)) {
return `Delay ${millisToString(entry.duration)}`;
}
return '';
}
@@ -253,7 +253,10 @@ export default function RundownEvent({
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
// while dragging, the element is represented by the drag overlay
// we keep the original element in place as a placeholder
transform: isDragging ? undefined : CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : undefined,
transition,
};
@@ -139,7 +139,10 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
// while dragging, the element is represented by the drag overlay
// we keep the original element in place as a placeholder
transform: isDragging ? undefined : CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : undefined,
transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'inherit',
};
@@ -78,7 +78,10 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
// while dragging, the element is represented by the drag overlay
// we keep the original element in place as a placeholder
transform: isDragging ? undefined : CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : undefined,
transition,
};