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