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 { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import {
|
import {
|
||||||
type EntryId,
|
type EntryId,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { Fragment, type HTMLProps, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, type HTMLProps, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled } from 'react-icons/tb';
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
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 { useRundownCommands } from './hooks/useRundownCommands';
|
||||||
import { useRundownDnd } from './hooks/useRundownDnd';
|
import { useRundownDnd } from './hooks/useRundownDnd';
|
||||||
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
|
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
|
||||||
|
import RundownDragPreview from './rundown-drag-preview/RundownDragPreview';
|
||||||
import RundownGroup from './rundown-group/RundownGroup';
|
import RundownGroup from './rundown-group/RundownGroup';
|
||||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||||
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||||
@@ -209,6 +211,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
// gather presentation options
|
// gather presentation options
|
||||||
const isEditMode = editorMode === AppMode.Edit;
|
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
|
// gather rundown wide data
|
||||||
const lastEntryId = order.at(-1);
|
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'>
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||||
<DndContext
|
<DndContext
|
||||||
onDragEnd={dnd.handleOnDragEnd}
|
onDragEnd={dnd.handleOnDragEnd}
|
||||||
onDragStart={dnd.collapseDraggedGroups}
|
onDragStart={dnd.handleOnDragStart}
|
||||||
|
onDragCancel={dnd.handleOnDragCancel}
|
||||||
onDragOver={dnd.expandOverGroup}
|
onDragOver={dnd.expandOverGroup}
|
||||||
sensors={dnd.sensors}
|
sensors={dnd.sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -357,6 +363,20 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SortableContext>
|
</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>
|
</DndContext>
|
||||||
</div>
|
</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 { type EntryId, type Rundown, SupportedEntry, isOntimeGroup } from 'ontime-types';
|
||||||
import { reorderArray } from 'ontime-utils';
|
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 type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { canDrop } from '../rundown.utils';
|
import { canDrop } from '../rundown.utils';
|
||||||
@@ -27,28 +35,46 @@ export function useRundownDnd({
|
|||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||||
const isDraggingRef = useRef(false);
|
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
|
* On drag end, we reorder the events
|
||||||
*/
|
*/
|
||||||
const handleOnDragEnd = useCallback(
|
const handleOnDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
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) {
|
if (!over?.id || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!active.data.current || !over.data.current) {
|
if (!activeData?.sortable || !over.data.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromIndex: number = active.data.current.sortable.index;
|
const fromIndex: number = activeData.sortable.index;
|
||||||
const toIndex: number = over.data.current.sortable.index;
|
const toIndex: number = over.data.current.sortable.index;
|
||||||
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||||
|
|
||||||
let destinationId = over.id as EntryId;
|
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
|
// prevent dropping a group inside another
|
||||||
if (
|
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
|
* This avoids strange scenarios like dropping a group inside itself
|
||||||
*/
|
*/
|
||||||
const collapseDraggedGroups = useCallback(
|
const handleOnDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
isDraggingRef.current = true;
|
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;
|
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
handleCollapseGroup(true, event.active.id as EntryId);
|
handleCollapseGroup(true, event.active.id as EntryId);
|
||||||
@@ -120,6 +150,13 @@ export function useRundownDnd({
|
|||||||
[handleCollapseGroup],
|
[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
|
* When we drag over a group, we expand it if it is collapsed
|
||||||
*/
|
*/
|
||||||
@@ -143,10 +180,12 @@ export function useRundownDnd({
|
|||||||
() => ({
|
() => ({
|
||||||
sensors,
|
sensors,
|
||||||
isDraggingRef,
|
isDraggingRef,
|
||||||
|
activeId,
|
||||||
handleOnDragEnd,
|
handleOnDragEnd,
|
||||||
collapseDraggedGroups,
|
handleOnDragStart,
|
||||||
|
handleOnDragCancel,
|
||||||
expandOverGroup,
|
expandOverGroup,
|
||||||
}),
|
}),
|
||||||
[sensors, handleOnDragEnd, collapseDraggedGroups, expandOverGroup],
|
[sensors, activeId, handleOnDragEnd, handleOnDragStart, handleOnDragCancel, expandOverGroup],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
|||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
zIndex: isDragging ? 2 : 'inherit',
|
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,
|
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 = {
|
const dragStyle = {
|
||||||
zIndex: isDragging ? 2 : 'inherit',
|
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,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
zIndex: isDragging ? 2 : 'inherit',
|
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,
|
transition,
|
||||||
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'inherit',
|
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'inherit',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
zIndex: isDragging ? 2 : 'inherit',
|
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,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user