From 23d1ebeac1f234073264f57c0a270f4b9beb4ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 18:39:58 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TEnUVARPnSqXijiz8KiLiE --- apps/client/src/features/rundown/Rundown.tsx | 24 +++++++- .../features/rundown/hooks/useRundownDnd.ts | 59 +++++++++++++++---- .../rundown/rundown-delay/RundownDelay.tsx | 5 +- .../RundownDragPreview.module.scss | 30 ++++++++++ .../RundownDragPreview.tsx | 55 +++++++++++++++++ .../rundown/rundown-event/RundownEvent.tsx | 5 +- .../rundown/rundown-group/RundownGroup.tsx | 5 +- .../rundown-milestone/RundownMilestone.tsx | 5 +- 8 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.module.scss create mode 100644 apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.tsx diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 9b96debce..b3890e17c 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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
+ {/** + * 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( + + {draggedEntry && ( + + )} + , + document.body, + )}
); diff --git a/apps/client/src/features/rundown/hooks/useRundownDnd.ts b/apps/client/src/features/rundown/hooks/useRundownDnd.ts index c883efd2d..9f504808f 100644 --- a/apps/client/src/features/rundown/hooks/useRundownDnd.ts +++ b/apps/client/src/features/rundown/hooks/useRundownDnd.ts @@ -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(null); + const [activeId, setActiveId] = useState(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], ); } diff --git a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx index 753a2166d..de8aa2a17 100644 --- a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx +++ b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx @@ -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, }; diff --git a/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.module.scss b/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.module.scss new file mode 100644 index 000000000..d53c22c8f --- /dev/null +++ b/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.module.scss @@ -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; +} diff --git a/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.tsx b/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.tsx new file mode 100644 index 000000000..25c08b8a9 --- /dev/null +++ b/apps/client/src/features/rundown/rundown-drag-preview/RundownDragPreview.tsx @@ -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 ( +
+
+ +
+
{getPreviewLabel(entry, eventIndex)}
+
+ ); +} + +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 ''; +} diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 0afbe8bdd..e7a1eae7c 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -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, }; diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index 91a8ca56d..84060e499 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -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', }; diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx index 83256ea38..786ae4747 100644 --- a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx @@ -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, };