mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-17 03:53:06 +00:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user