Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/lodash.isequal": "^4.5.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/wordpress__block-editor": "^11.5.17",
Expand All @@ -51,5 +52,8 @@
"ts-jest": "^29.2.3",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^5.9.2"
},
"dependencies": {
"lodash.isequal": "^4.5.0"
}
}
4 changes: 2 additions & 2 deletions src/CollaborativeEditing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useGutenbergEditorControls } from '@/hooks/useGutenbergEditorControls';
export const CollaborativeEditing = () => {
const { currentUserId, collaborationMode, state, syncAwareness } =
useDataManager();
const { isLockHolder, activeUsers, otherActiveUsers } = state;
const { isLockHolder, activeUsers } = state;

useCSSClassManager( collaborationMode, activeUsers, isLockHolder );
useGutenbergEditorControls( collaborationMode, isLockHolder );
Expand Down Expand Up @@ -46,7 +46,7 @@ export const CollaborativeEditing = () => {
return (
<>
<PresenceUI
awarenessState={ otherActiveUsers }
awarenessState={ state.activeUsers }
syncAwareness={ syncAwareness }
currentUserId={ currentUserId }
/>
Expand Down
175 changes: 155 additions & 20 deletions src/components/PresenceUI.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { createPortal, useEffect } from '@wordpress/element';
import { createPortal, useEffect, useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { useMultiCursor } from '@/useMultiCursor';
import AvatarList from './AvatarList';
import { CursorState, AwarenessState } from '@/hooks/types';
import { useCursorState } from '@/hooks/useCursorState';
import isEqual from 'lodash.isequal';

interface Occupant {
userId: number | null;
timestamp: number;
}

interface BlockOccupants {
[ blockId: string ]: Occupant[];
}

interface PresenceUIProps {
awarenessState: AwarenessState;
Expand All @@ -16,42 +27,166 @@
currentUserId,
}: PresenceUIProps ) => {
const { setLockedBlocks } = useDispatch( 'gce' );
const currentUserCursorState = useCursorState();
const [ blockOccupants, setBlockOccupants ] = useState< BlockOccupants >(
{}
);

const otherUsers = awarenessState;
const allUsers = awarenessState;
const currentUser = currentUserId ? allUsers[ currentUserId ] : null;
const otherUsers = Object.keys( allUsers ).reduce( ( acc, key ) => {
const userId = Number( key );
if ( userId !== currentUserId ) {
acc[ userId ] = allUsers[ userId ];
}
return acc;
}, {} as AwarenessState );

useMultiCursor( currentUserId, otherUsers, syncAwareness );

// Assume user's cursors carries edit intent
// so lock all blocks where cursors of other users are at
useEffect( () => {
const lockedBlocks: string[] = [];
const newBlockOccupants: BlockOccupants = { ...blockOccupants };

// Helper to get block index from cursor state
const getBlockIndex = ( state: CursorState | null ) => {
if ( ! state ) {
return null;
}
return 'blockIndex' in state
? state.blockIndex
: state.blockIndexStart;
};

// Remove users who are no longer present
Object.keys( newBlockOccupants ).forEach( ( blockId ) => {
newBlockOccupants[ blockId ] = newBlockOccupants[ blockId ].filter(
( occupant: Occupant ) =>
Object.values( otherUsers ).some(
( user ) => user.user_data.id === occupant.userId
) || occupant.userId === currentUserId
);
} );

// Update occupants based on other users' positions
Object.values( otherUsers ).forEach( ( user ) => {
if ( ! user.cursor_state ) {
// eslint-disable-next-line no-console
console.log( '🚧 when is this happening?', user );
const blockIndex = getBlockIndex( user.cursor_state );
if ( blockIndex === null ) {
return;
}
const blockIndex =
'blockIndex' in user.cursor_state
? user.cursor_state.blockIndex
: user.cursor_state.blockIndexStart;

const existingBlocks = wp.data
.select( 'core/block-editor' )
.getBlocks();

const block = existingBlocks[ blockIndex ];
if ( block ) {
lockedBlocks.push( block.clientId );

// Remove user from any other block they might have been in
Object.keys( newBlockOccupants ).forEach( ( blockId ) => {
if ( blockId !== blockIndex.toString() ) {
newBlockOccupants[ blockId ] = newBlockOccupants[
blockId
].filter(
( occupant: Occupant ) =>
occupant.userId !== user.user_data.id
);
}
} );

if ( ! newBlockOccupants[ blockIndex ] ) {
newBlockOccupants[ blockIndex ] = [];
}
if (
! newBlockOccupants[ blockIndex ].some(
( o: Occupant ) => o.userId === user.user_data.id
)
) {
newBlockOccupants[ blockIndex ].push( {
userId: user.user_data.id,
timestamp: user.cursor_ts,
} );
}
} );

// Update for the current user
const currentUserBlockIndex = getBlockIndex( currentUserCursorState );
Object.keys( newBlockOccupants ).forEach( ( blockId ) => {
if (
currentUserBlockIndex === null ||
blockId !== currentUserBlockIndex.toString()
) {
newBlockOccupants[ blockId ] = newBlockOccupants[
blockId
].filter(
( occupant: Occupant ) => occupant.userId !== currentUserId
);
}
} );

if ( currentUserBlockIndex !== null ) {
if ( ! newBlockOccupants[ currentUserBlockIndex ] ) {
newBlockOccupants[ currentUserBlockIndex ] = [];
}
if (
! newBlockOccupants[ currentUserBlockIndex ].some(
( o: Occupant ) => o.userId === currentUserId
)
) {
if ( currentUser ) {
newBlockOccupants[ currentUserBlockIndex ].push( {
userId: currentUserId,
timestamp: currentUser.cursor_ts,
} );
}
}
}

if ( ! isEqual( blockOccupants, newBlockOccupants ) ) {
setBlockOccupants( newBlockOccupants );
}

// Determine locked blocks
const lockedBlocks: string[] = [];
Object.entries( newBlockOccupants ).forEach(
( [ blockIndex, occupants ] ) => {
if ( occupants.length > 1 ) {
const sortedOccupants = [ ...occupants ].sort(
( a, b ) => a.timestamp - b.timestamp
);
const lockHolder = sortedOccupants[ 0 ];

if ( currentUserId !== lockHolder.userId ) {
const existingBlocks = wp.data
.select( 'core/block-editor' )
.getBlocks();
const block =
existingBlocks[ parseInt( blockIndex, 10 ) ];
if ( block ) {
lockedBlocks.push( block.clientId );
}
}
} else if (
occupants.length === 1 &&
occupants[ 0 ].userId !== currentUserId
) {
const existingBlocks = wp.data
.select( 'core/block-editor' )
.getBlocks();
const block = existingBlocks[ parseInt( blockIndex, 10 ) ];
if ( block ) {
lockedBlocks.push( block.clientId );
}
}
}
);

setLockedBlocks( lockedBlocks );

// Cleanup function to remove locks when users leave
return () => {
setLockedBlocks( [] );
};
}, [ otherUsers, setLockedBlocks ] );
}, [

Check warning on line 183 in src/components/PresenceUI.tsx

View workflow job for this annotation

GitHub Actions / build

React Hook useEffect has a missing dependency: 'currentUser'. Either include it or remove the dependency array
otherUsers,
setLockedBlocks,
currentUserCursorState,
currentUserId,
blockOccupants,
] );

if ( ! otherUsers || Object.keys( otherUsers ).length === 0 ) {
return null;
Expand Down
10 changes: 9 additions & 1 deletion src/hooks/useDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ const handleDataReceived = (
cursorState && 'blockIndex' in cursorState
? cursorState.blockIndex
: undefined;
const lockedBlocks = wp.data.select( 'gce' ).getLockedBlocks();
const engagedBlockClientId =
engagedBlockIndex !== undefined
? existingBlocks[ engagedBlockIndex ]?.clientId
: undefined;
const isEngagedBlockLocked =
engagedBlockClientId &&
lockedBlocks.includes( engagedBlockClientId );

// The tracker expects a simplified `Block` object.
const mappedBlocks: Block[] = receivedBlocks.map( ( block ) => ( {
Expand All @@ -152,7 +160,7 @@ const handleDataReceived = (
const blocksToSet = mergeBlocks(
existingBlocks,
receivedBlocks,
engagedBlockIndex ?? -1
isEngagedBlockLocked ? -1 : engagedBlockIndex ?? -1
);

resetBlocks( blocksToSet );
Expand Down