Skip to content

Commit

Permalink
Add Drag and Drop functionality to Breakout Rooms
Browse files Browse the repository at this point in the history
  • Loading branch information
Sullfred committed Feb 24, 2025
1 parent 0c8972f commit 943583e
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 16 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"author": "Håvar Aambø Fosstveit <[email protected]>",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
Expand Down
28 changes: 24 additions & 4 deletions src/components/breakoutrooms/ListBreakoutRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import EjectBreakoutRoomButton from '../textbuttons/EjectBreakoutRoomButton';
import RemoveBreakoutRoomButton from '../textbuttons/RemoveBreakoutRoomButton';
import ListMe from '../participantlist/ListMe';
import { useState } from 'react';
import GhostObject from '../draganddrop/GhostObject';
import DraggableWrapper from '../draganddrop/DraggableWrapper';
import { isMobileSelector, peersArraySelector } from '../../store/selectors';

interface BreakoutRoomProps {
room: RoomSession;
canChangeRoom: boolean;
canCreateRoom: boolean;
isModerator: boolean;
dragOver: string;
draggedPeerids: string[];
}

interface AccordionProps {
Expand Down Expand Up @@ -71,16 +76,22 @@ const ListBreakoutRoom = ({
canChangeRoom,
canCreateRoom,
isModerator,
dragOver,
draggedPeerids,
}: BreakoutRoomProps): JSX.Element => {
const [ expanded, setExpanded ] = useState(false);
const sessionId = useAppSelector((state) => state.me.sessionId);
const inSession = room.sessionId === sessionId;
const participants = usePeersInSession(room.sessionId);
const isMobile = useAppSelector(isMobileSelector);
const showParticipantsList = participants.filter((peer) => !draggedPeerids.includes(peer.id));
const dragExpand = dragOver === room.sessionId ? true : false;
const showGhosts = (useAppSelector(peersArraySelector)).filter((peer) => draggedPeerids.includes(peer.id));

return (
<StyledAccordion
TransitionProps={{ unmountOnExit: true }}
expanded={inSession || expanded}
expanded={inSession || expanded || dragExpand }
onChange={(_, exp) => setExpanded(exp)}
insession={inSession ? 1 : 0}
>
Expand All @@ -103,12 +114,21 @@ const ListBreakoutRoom = ({
))}
</AccordionSummary>
<BreakoutRoomAccordionDetails>
{dragExpand ? (
showGhosts.map((peer) => (
<GhostObject key={peer.id}>
<ListPeer key={peer.id} peer={peer} isModerator={isModerator} />
</GhostObject>
))
): null }
{ inSession && <ListMe /> }
{ participants.length > 0 &&
<Flipper flipKey={participants}>
{ participants.map((peer) => (
<Flipper flipKey={showParticipantsList}>
{ showParticipantsList.map((peer) => (
<Flipped key={peer.id} flipId={peer.id}>
<ListPeer key={peer.id} peer={peer} isModerator={isModerator} />
<DraggableWrapper key={peer.id} id={peer.id} disabled={!isModerator || isMobile}>
<ListPeer key={peer.id} peer={peer} isModerator={isModerator} />
</DraggableWrapper>
</Flipped>
)) }
</Flipper>
Expand Down
153 changes: 153 additions & 0 deletions src/components/draganddrop/DragAndDropContextWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React from 'react';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import { moveToBreakoutRoom } from '../../store/actions/roomActions';
import { Peer } from '../../store/slices/peersSlice';
import { roomSessionsActions } from '../../store/slices/roomSessionsSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { parentParticipantListSelector, currentRoomSessionSelector, peersArraySelector, roomSessionsArraySelector } from '../../store/selectors';

type DndContextWrapperProps = {
children: React.ReactNode;
// eslint-disable-next-line no-unused-vars
setShowParticipantsList: (showParticipantsList: Peer[]) => void;
// eslint-disable-next-line no-unused-vars
setDragOver: (dragOver: string) => void;
draggedPeerIds: string[];
// eslint-disable-next-line no-unused-vars
setDraggedPeerIds: (draggedPeerIds: string[]) => void;
activePeer: Peer | null;
// eslint-disable-next-line no-unused-vars
setActivePeer: (activePeer: Peer | null) => void;
}

const DndContextWrapper = ({ children, setShowParticipantsList, setDragOver, draggedPeerIds, setDraggedPeerIds, activePeer, setActivePeer }: DndContextWrapperProps): JSX.Element => {
const dispatch = useAppDispatch();
const participants = useAppSelector(parentParticipantListSelector);
const currentRoom = useAppSelector(currentRoomSessionSelector);
const totalPeers = useAppSelector(peersArraySelector);
const allRooms = useAppSelector(roomSessionsArraySelector);
const selectedPeersMap = new Map();

for (let i = 0; i < allRooms.length; i++) {
selectedPeersMap.set(allRooms[i].sessionId, allRooms[i].selectedPeers);
}

// Drag activation constraints
const pointerSensor = useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
}
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(
pointerSensor,
keyboardSensor,
);

// Function to deselect all peers not being dragged
const deselectPeers = (ids: string[]) => {
selectedPeersMap.forEach((selectedPeers: string[], roomid: string) => {
selectedPeers.forEach((peerId) => {
if (!ids.includes(peerId)) {
dispatch(roomSessionsActions.deselectPeer({ sessionId: roomid, peerId: peerId }));
}
});
});
};

const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const activeId = active.id as string;
const draggedPeer = totalPeers.find((peer) => peer.id == activeId);

// Ensure we are dragging a peer
if (!draggedPeer) {
return;
}

const dragging = !currentRoom.selectedPeers.includes(activeId) ? [ activeId ] : [ ...new Set([ ...currentRoom.selectedPeers, activeId ]) ];
const newList = participants.filter((peer) => !dragging.includes(peer.id));
const deselect = !Array.from(selectedPeersMap.values()).flat()
.every((peerId) => dragging.includes(peerId));

// Deselect peers if there are selected peers not being dragged
if (deselect) {
deselectPeers(dragging);
}

setActivePeer(draggedPeer);
setDraggedPeerIds(dragging);
setShowParticipantsList(newList);
};

const handleDragEnd = (event: DragEndEvent) => {
const { over } = event;

// Ensure over some droppable component
if (!over) {
setShowParticipantsList(participants);
setDraggedPeerIds([]);
setActivePeer(null);

return;
}

const roomId = over.id as string;

// Drop peer if peer is over a new breakout room
if (roomId !== activePeer?.sessionId && draggedPeerIds) {
draggedPeerIds.forEach((peerId) => {
dispatch(moveToBreakoutRoom(peerId, roomId, currentRoom.sessionId));
});
}

// Reset when drag has ended
// setShowParticipantsList(participants); // Uncomment if necessary but will produce a visual glitch
setDraggedPeerIds([]);
setActivePeer(null);
setDragOver('');
};

const handleDragCancel = () => {
// Reset all when drag is cancelled
setShowParticipantsList(participants);
setDraggedPeerIds([]);
setActivePeer(null);
setDragOver('');
};

const handleDragOver = (event: DragOverEvent) => {
const { over } = event;

if (!over) {
setDragOver('');

return;
}

setDragOver(over.id as string);
};

return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
onDragOver={handleDragOver}
>
{children}
</DndContext>
);
};

export default DndContextWrapper;
31 changes: 31 additions & 0 deletions src/components/draganddrop/DraggableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';

type DraggableElementProps = {
children: React.ReactNode;
id: string;
disabled: boolean;
};

const DraggableWrapper = ({ children, id, disabled }: DraggableElementProps): JSX.Element => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: id,
disabled: disabled
});
const style ={
cursor: disabled ? 'auto' : 'grab'
};

return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={style}
>
{children}
</div>
);
};

export default DraggableWrapper;
23 changes: 23 additions & 0 deletions src/components/draganddrop/DroppableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { useDroppable } from '@dnd-kit/core';

type DroppableElementProps = {
children: React.ReactNode;
id: string;
};

const DroppableWrapper = ({ children, id }: DroppableElementProps): JSX.Element => {
const { setNodeRef } = useDroppable({
id: id,
});

return (
<div
ref={setNodeRef}
>
{children}
</div>
);
};

export default DroppableWrapper;
37 changes: 37 additions & 0 deletions src/components/draganddrop/GhostObject.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Box, styled } from '@mui/material';

type GhostObjectProps = {
children: React.ReactNode;
};

// eslint-disable-next-line no-empty-pattern
const GhostDiv = styled(Box)(({ }) => ({
opacity: 0.7,
animation: 'pulse 3s ease-in-out 0.5s infinite',

'@keyframes pulse': {
'0%': {
opacity: 0.9,
},

'50%': {
opacity: 0.6,
},

'100%': {
opacity: 0.9,
},
}
}));

// Pulsing object wrapper to show were peers wil be dropped
const GhostObject = ({ children }: GhostObjectProps): JSX.Element => {

return (
<GhostDiv>
{children}
</GhostDiv>
);
};

export default GhostObject;
Loading

0 comments on commit 943583e

Please sign in to comment.