diff --git a/package.json b/package.json index 05dd716c7..85a3bd284 100644 --- a/package.json +++ b/package.json @@ -88,12 +88,15 @@ "classnames": "^2.3.1", "debug": "^4.3.2", "eslint-plugin-eslint-comments": "^3.2.0", + "lodash": "^4.17.21", + "memize": "^1.1.0", "react": "17.0.2", "react-autosize-textarea": "^7.1.0", "react-dom": "17.0.2", "reakit-utils": "^0.15.2", "redux-undo": "^1.0.1", "refx": "^3.1.1", + "uuid": "^8.3.2", "yjs": "^13.5.12" }, "devDependencies": { diff --git a/src/components/collaborative-editing/components/avatars/Avatars.stories.tsx b/src/components/collaborative-editing/components/avatars/Avatars.stories.tsx index c2194058a..1c063ba14 100644 --- a/src/components/collaborative-editing/components/avatars/Avatars.stories.tsx +++ b/src/components/collaborative-editing/components/avatars/Avatars.stories.tsx @@ -1,5 +1,11 @@ +/** + * External dependencies + */ import { sample } from 'lodash'; +/** + * Internal dependencies + */ import { CollaborativeEditingAvatars, CollaborativeEditingAvatar } from '.'; import { defaultColors } from '../../use-yjs'; @@ -15,6 +21,7 @@ const generateRandomPeers = ( count ) => { ? [ ...Array( count ) ].map( ( peer, index ) => ( { id: index.toString(), name: `Peery Collabson ${ index }`, + // eslint-disable-next-line no-restricted-syntax avatarUrl: `https://i.pravatar.cc/64?cacheBust=${ Math.random() }`, color: sample( defaultColors ) as string, } ) ) diff --git a/src/components/collaborative-editing/components/avatars/index.js b/src/components/collaborative-editing/components/avatars/index.js index 0d8243730..98743641b 100644 --- a/src/components/collaborative-editing/components/avatars/index.js +++ b/src/components/collaborative-editing/components/avatars/index.js @@ -6,10 +6,13 @@ import { withSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ import './style.scss'; /** - * @param {object} props + * @param {Object} props * @param {import("../..").AvailablePeer[]} props.peers */ export function CollaborativeEditingAvatars( { peers } ) { diff --git a/src/components/collaborative-editing/index.js b/src/components/collaborative-editing/index.js index 26730e628..bb97f931f 100644 --- a/src/components/collaborative-editing/index.js +++ b/src/components/collaborative-editing/index.js @@ -7,6 +7,7 @@ import CollaborativeEditingAvatars from './components/avatars'; /** * Real-time collaboration settings + * * @typedef CollaborationSettings * @property {boolean} enabled * @property {string} [channelId] Optional channel id to pass to transport.connect(). @@ -18,13 +19,16 @@ import CollaborativeEditingAvatars from './components/avatars'; /** * Transport module for real-time collaboration + * * @typedef CollaborationTransport * @property {(message: CollaborationTransportDocMessage|CollaborationTransportSelectionMessage) => void} sendMessage * @property {(options: CollaborationTransportConnectOpts) => Promise<{isFirstInChannel: boolean}>} connect * @property {() => Promise} disconnect - * + */ + +/** * @typedef CollaborationTransportConnectOpts - * @property {object} user + * @property {Object} user * @property {string} user.identity * @property {string} user.name * @property {string} [user.avatarUrl] @@ -32,30 +36,38 @@ import CollaborativeEditingAvatars from './components/avatars'; * @property {(message: object) => void} onReceiveMessage Callback to run when a message is received. * @property {(peers: AvailablePeer[]) => void} setAvailablePeers Callback to run when peers change. * @property {string} [channelId] - * + */ + +/** * @typedef AvailablePeer * @property {string} id * @property {string} name * @property {string} color * @property {string} [avatarUrl] - * + */ + +/** * @typedef CollaborationTransportDocMessage * @property {string} identity * @property {'doc'} type - * @property {object} message - * + * @property {Object} message + */ + +/** * @typedef CollaborationTransportSelectionMessage * @property {string} identity * @property {'selection'} type * @property {EditorSelection} selection - * + */ + +/** * @typedef EditorSelection - * @property {object} start - * @property {object} end + * @property {Object} start + * @property {Object} end */ /** - * @param {object} props + * @param {Object} props * @param {CollaborationSettings} props.settings */ function CollaborativeEditing( { settings } ) { diff --git a/src/components/collaborative-editing/use-yjs/algorithms/__tests__/yjs.js b/src/components/collaborative-editing/use-yjs/algorithms/__tests__/yjs.js index 96e1fea66..b63bd8556 100644 --- a/src/components/collaborative-editing/use-yjs/algorithms/__tests__/yjs.js +++ b/src/components/collaborative-editing/use-yjs/algorithms/__tests__/yjs.js @@ -1,4 +1,11 @@ +/** + * External dependencies + */ import * as yjs from 'yjs'; + +/** + * Internal dependencies + */ import { updateBlocksDoc, blocksDocToArray } from '../yjs'; jest.mock( 'uuid', () => { @@ -24,11 +31,7 @@ function applyYjsUpdate( yDoc, update ) { } ); } -async function getUpdatedBlocksUsingYjsAlgo( - originalBlocks, - updatedLocalBlocks, - updatedRemoteBlocks -) { +async function getUpdatedBlocksUsingYjsAlgo( originalBlocks, updatedLocalBlocks, updatedRemoteBlocks ) { // Local doc. const localYDoc = new yjs.Doc(); const localYBlocks = localYDoc.getMap( 'blocks' ); @@ -69,10 +72,7 @@ async function getUpdatedBlocksUsingYjsAlgo( ); // Merging remote edit into local edit. - await applyYjsUpdate( - localYDoc, - yjs.encodeStateAsUpdate( remoteYDoc ) - ); + await applyYjsUpdate( localYDoc, yjs.encodeStateAsUpdate( remoteYDoc ) ); } return blocksDocToArray( localYBlocks ); @@ -105,13 +105,9 @@ syncAlgorithms.forEach( ( { name, algo } ) => { }, ]; - expect( - await algo( - originalBlocks, - updatedLocalBlocks, - updateRemoteBlocks - ) - ).toEqual( updateRemoteBlocks ); + expect( await algo( originalBlocks, updatedLocalBlocks, updateRemoteBlocks ) ).toEqual( + updateRemoteBlocks + ); } ); test( 'New local block and remote update to single block.', async () => { @@ -168,13 +164,7 @@ syncAlgorithms.forEach( ( { name, algo } ) => { }, ]; - expect( - await algo( - originalBlocks, - updatedLocalBlocks, - updateRemoteBlocks - ) - ).toEqual( expectedMerge ); + expect( await algo( originalBlocks, updatedLocalBlocks, updateRemoteBlocks ) ).toEqual( expectedMerge ); } ); test( 'Local deletion of multiple blocks and update to single block.', async () => { @@ -223,13 +213,7 @@ syncAlgorithms.forEach( ( { name, algo } ) => { }, ]; - expect( - await algo( - originalBlocks, - updatedLocalBlocks, - updateRemoteBlocks - ) - ).toEqual( expectedMerge ); + expect( await algo( originalBlocks, updatedLocalBlocks, updateRemoteBlocks ) ).toEqual( expectedMerge ); } ); test( 'Moving a block locally while updating it remotely.', async () => { @@ -300,13 +284,7 @@ syncAlgorithms.forEach( ( { name, algo } ) => { }, ]; - expect( - await algo( - originalBlocks, - updatedLocalBlocks, - updateRemoteBlocks - ) - ).toEqual( expectedMerge ); + expect( await algo( originalBlocks, updatedLocalBlocks, updateRemoteBlocks ) ).toEqual( expectedMerge ); } ); test( 'Moving a block to inner blocks while updating it remotely.', async () => { @@ -379,13 +357,7 @@ syncAlgorithms.forEach( ( { name, algo } ) => { }, ]; - expect( - await algo( - originalBlocks, - updatedLocalBlocks, - updateRemoteBlocks - ) - ).toEqual( expectedMerge ); + expect( await algo( originalBlocks, updatedLocalBlocks, updateRemoteBlocks ) ).toEqual( expectedMerge ); } ); } ); } ); diff --git a/src/components/collaborative-editing/use-yjs/algorithms/yjs.js b/src/components/collaborative-editing/use-yjs/algorithms/yjs.js index 5d87eebf9..8c7d0b88d 100644 --- a/src/components/collaborative-editing/use-yjs/algorithms/yjs.js +++ b/src/components/collaborative-editing/use-yjs/algorithms/yjs.js @@ -1,5 +1,8 @@ // @ts-nocheck TODO +/** + * External dependencies + */ import * as yjs from 'yjs'; import { isEqual } from 'lodash'; @@ -58,20 +61,13 @@ export function updateBlocksDoc( yDocBlocks, blocks, clientId = '' ) { ); currentOrder .slice( orderDiff.index, orderDiff.remove ) - .forEach( - ( _clientId ) => - ! orderDiff.insert.includes( _clientId ) && - byClientId.delete( _clientId ) - ); + .forEach( ( _clientId ) => ! orderDiff.insert.includes( _clientId ) && byClientId.delete( _clientId ) ); order.delete( orderDiff.index, orderDiff.remove ); order.insert( orderDiff.index, orderDiff.insert ); for ( const _block of blocks ) { const { innerBlocks, ...block } = _block; - if ( - ! byClientId.has( block.clientId ) || - ! isEqual( byClientId.get( block.clientId ), block ) - ) { + if ( ! byClientId.has( block.clientId ) || ! isEqual( byClientId.get( block.clientId ), block ) ) { byClientId.set( block.clientId, block ); } @@ -94,16 +90,7 @@ export function updateCommentsDoc( commentsDoc, comments = [] ) { } currentDoc = commentsDoc.get( comment._id ); // Update regular fields - [ - 'type', - 'content', - 'createdAt', - 'status', - 'start', - 'end', - 'authorId', - 'authorName', - ].forEach( ( field ) => { + [ 'type', 'content', 'createdAt', 'status', 'start', 'end', 'authorId', 'authorName' ].forEach( ( field ) => { if ( isNewDoc || currentDoc.get( field ) !== comment[ field ] ) { currentDoc.set( field, comment[ field ] ); } @@ -131,16 +118,11 @@ export function updateCommentRepliesDoc( repliesDoc, replies = [] ) { repliesDoc.set( reply._id, new yjs.Map() ); } currentReplyDoc = repliesDoc.get( reply._id ); - [ 'content', 'createdAt', 'authorId', 'authorName' ].forEach( - ( field ) => { - if ( - isNewDoc || - currentReplyDoc.get( field ) !== reply[ field ] - ) { - currentReplyDoc.set( field, reply[ field ] ); - } + [ 'content', 'createdAt', 'authorId', 'authorName' ].forEach( ( field ) => { + if ( isNewDoc || currentReplyDoc.get( field ) !== reply[ field ] ) { + currentReplyDoc.set( field, reply[ field ] ); } - ); + } ); } ); } @@ -176,32 +158,30 @@ export function commentsDocToArray( commentsDoc ) { return []; } - return Object.entries( commentsDoc.toJSON() ).map( - ( [ id, commentDoc ] ) => { - return { - _id: id, - type: commentDoc.type, - content: commentDoc.content, - createdAt: commentDoc.createdAt, - status: commentDoc.status, - start: commentDoc.start, - end: commentDoc.end, - authorId: commentDoc.authorId, - authorName: commentDoc.authorName, - replies: Object.entries( commentDoc.replies ) - .map( ( [ replyId, entryDoc ] ) => { - return { - _id: replyId, - content: entryDoc.content, - createdAt: entryDoc.createdAt, - authorId: entryDoc.authorId, - authorName: entryDoc.authorName, - }; - } ) - .sort( ( a, b ) => a.createdAt - b.createdAt ), - }; - } - ); + return Object.entries( commentsDoc.toJSON() ).map( ( [ id, commentDoc ] ) => { + return { + _id: id, + type: commentDoc.type, + content: commentDoc.content, + createdAt: commentDoc.createdAt, + status: commentDoc.status, + start: commentDoc.start, + end: commentDoc.end, + authorId: commentDoc.authorId, + authorName: commentDoc.authorName, + replies: Object.entries( commentDoc.replies ) + .map( ( [ replyId, entryDoc ] ) => { + return { + _id: replyId, + content: entryDoc.content, + createdAt: entryDoc.createdAt, + authorId: entryDoc.authorId, + authorName: entryDoc.authorName, + }; + } ) + .sort( ( a, b ) => a.createdAt - b.createdAt ), + }; + } ); } /** diff --git a/src/components/collaborative-editing/use-yjs/filters/block-selection/index.js b/src/components/collaborative-editing/use-yjs/filters/block-selection/index.js index 84d2a9357..278182b11 100644 --- a/src/components/collaborative-editing/use-yjs/filters/block-selection/index.js +++ b/src/components/collaborative-editing/use-yjs/filters/block-selection/index.js @@ -4,6 +4,9 @@ import { addFilter } from '@wordpress/hooks'; import { useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ import './style.scss'; /** diff --git a/src/components/collaborative-editing/use-yjs/filters/index.js b/src/components/collaborative-editing/use-yjs/filters/index.js index 58d77ceb6..68718110d 100644 --- a/src/components/collaborative-editing/use-yjs/filters/index.js +++ b/src/components/collaborative-editing/use-yjs/filters/index.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import { addFilterCollabBlockSelection } from './block-selection'; export const addCollabFilters = () => { diff --git a/src/components/collaborative-editing/use-yjs/formats/collab-caret/CollabCaret.stories.tsx b/src/components/collaborative-editing/use-yjs/formats/collab-caret/CollabCaret.stories.tsx index 0cb3866d0..2c4b3c388 100644 --- a/src/components/collaborative-editing/use-yjs/formats/collab-caret/CollabCaret.stories.tsx +++ b/src/components/collaborative-editing/use-yjs/formats/collab-caret/CollabCaret.stories.tsx @@ -1,5 +1,11 @@ +/** + * External dependencies + */ import type { Story } from '@storybook/react'; +/** + * Internal dependencies + */ import { applyCarets, settings } from '.'; export default { diff --git a/src/components/collaborative-editing/use-yjs/formats/index.js b/src/components/collaborative-editing/use-yjs/formats/index.js index 17cc751b7..9eae49ddc 100644 --- a/src/components/collaborative-editing/use-yjs/formats/index.js +++ b/src/components/collaborative-editing/use-yjs/formats/index.js @@ -1,3 +1,6 @@ +/** + * Internal dependencies + */ import { registerFormatCollabCaret } from './collab-caret'; export const registerCollabFormats = () => { diff --git a/src/components/collaborative-editing/use-yjs/index.js b/src/components/collaborative-editing/use-yjs/index.js index e42d6fdce..6a1001791 100644 --- a/src/components/collaborative-editing/use-yjs/index.js +++ b/src/components/collaborative-editing/use-yjs/index.js @@ -3,6 +3,10 @@ */ import { v4 as uuidv4 } from 'uuid'; import { noop, sample } from 'lodash'; + +/** + * Internal dependencies + */ import { createDocument } from './yjs-doc'; import { postDocToObject, updatePostDoc } from './algorithms/yjs'; @@ -30,21 +34,20 @@ const debug = require( 'debug' )( 'iso-editor:collab' ); export const defaultColors = [ '#4676C0', '#6F6EBE', '#9063B6', '#C3498D', '#9E6D14', '#3B4856', '#4A807A' ]; /** - * @param {object} opts - Hook options + * @param {Object} opts - Hook options * @param {() => object[]} opts.getBlocks - Content to initialize the Yjs doc with. * @param {OnUpdate} opts.onRemoteDataChange - Function to update editor blocks in redux state. * @param {CollaborationSettings} opts.settings * @param {import('../../../store/peers/actions').setAvailablePeers} opts.setAvailablePeers * @param {import('../../../store/peers/actions').setPeerSelection} opts.setPeerSelection - * * @typedef IsoEditorSelection - * @property {object} selectionStart - * @property {object} selectionEnd + * @property {Object} selectionStart + * @property {Object} selectionEnd */ async function initYDoc( { getBlocks, onRemoteDataChange, settings, setPeerSelection, setAvailablePeers } ) { const { channelId, transport } = settings; - /** @type string */ + /** @type {string} */ const identity = uuidv4(); debug( `initYDoc (identity: ${ identity })` ); @@ -53,7 +56,7 @@ async function initYDoc( { getBlocks, onRemoteDataChange, settings, setPeerSelec identity, applyDataChanges: updatePostDoc, getData: postDocToObject, - /** @param {object} message */ + /** @param {Object} message */ sendMessage: ( message ) => { debug( 'sendDocMessage', message ); transport.sendMessage( { type: 'doc', identity, message } ); @@ -142,7 +145,7 @@ async function initYDoc( { getBlocks, onRemoteDataChange, settings, setPeerSelec } /** - * @param {object} opts - Hook options + * @param {Object} opts - Hook options * @param {CollaborationSettings} [opts.settings] */ export default function useYjs( { settings } ) { @@ -166,6 +169,7 @@ export default function useYjs( { settings } ) { } if ( ! settings.transport ) { + // eslint-disable-next-line no-console console.error( `Collaborative editor is disabled because a transport module wasn't provided.` ); return; } diff --git a/src/components/collaborative-editing/use-yjs/yjs-doc.js b/src/components/collaborative-editing/use-yjs/yjs-doc.js index ba5ae2e60..8da52dc6b 100644 --- a/src/components/collaborative-editing/use-yjs/yjs-doc.js +++ b/src/components/collaborative-editing/use-yjs/yjs-doc.js @@ -1,14 +1,12 @@ +/** + * External dependencies + */ import * as yjs from 'yjs'; const encodeArray = ( array ) => array.toString(); const decodeArray = ( string ) => new Uint8Array( string.split( ',' ) ); -export function createDocument( { - identity, - applyDataChanges, - getData, - sendMessage, -} ) { +export function createDocument( { identity, applyDataChanges, getData, sendMessage } ) { const doc = new yjs.Doc(); let state = 'off'; let listeners = []; @@ -85,21 +83,13 @@ export function createDocument( { switch ( messageType ) { case 'sync1': - if ( - content.destination && - content.destination !== identity - ) { + if ( content.destination && content.destination !== identity ) { return; } sendMessage( { protocol: 'yjs1', messageType: 'sync2', - update: encodeArray( - yjs.encodeStateAsUpdate( - doc, - decodeArray( content.stateVector ) - ) - ), + update: encodeArray( yjs.encodeStateAsUpdate( doc, decodeArray( content.stateVector ) ) ), destination: origin, } ); if ( ! content.isReply ) { @@ -110,19 +100,11 @@ export function createDocument( { if ( content.destination !== identity ) { return; } - yjs.applyUpdate( - doc, - decodeArray( content.update ), - origin - ); + yjs.applyUpdate( doc, decodeArray( content.update ), origin ); setState( 'on' ); break; case 'syncUpdate': - yjs.applyUpdate( - doc, - decodeArray( content.update ), - origin - ); + yjs.applyUpdate( doc, decodeArray( content.update ), origin ); break; } }, @@ -139,9 +121,7 @@ export function createDocument( { stateListeners.push( listener ); return () => { - stateListeners = stateListeners.filter( - ( l ) => l !== listener - ); + stateListeners = stateListeners.filter( ( l ) => l !== listener ); }; }, diff --git a/stories/collab/Collaboration.stories.tsx b/stories/collab/Collaboration.stories.tsx index bb00413c8..f4951c61a 100644 --- a/stories/collab/Collaboration.stories.tsx +++ b/stories/collab/Collaboration.stories.tsx @@ -1,10 +1,19 @@ +/** + * Internal dependencies + */ import IsolatedBlockEditor, { BlockEditorSettings, CollaborativeEditing } from '../../src/index'; import mockTransport, { resetPeers, setUpForceRemount } from './mock-transport'; import type { CollaborationSettings } from '../../src/components/collaborative-editing'; +/** + * External dependencies + */ import { random, sample } from 'lodash'; import type { Story } from '@storybook/react'; +/** + * WordPress dependencies + */ import { useEffect, useState } from '@wordpress/element'; export default { diff --git a/stories/collab/mock-transport.js b/stories/collab/mock-transport.js index 11c1d69cd..f34010a6c 100644 --- a/stories/collab/mock-transport.js +++ b/stories/collab/mock-transport.js @@ -1,7 +1,10 @@ const listeners = []; const disconnectHandlers = []; -/** @return {import("../../src/components/block-editor-contents/use-yjs").CollaborationTransport} */ +/** + * @param channelId + * @return {import("../../src/components/block-editor-contents/use-yjs").CollaborationTransport} + */ const mockTransport = ( channelId ) => ( { sendMessage: ( data ) => { window.localStorage.setItem( `isoEditorYjsMessage-${ channelId }`, JSON.stringify( data ) ); diff --git a/yarn.lock b/yarn.lock index d14f08d05..5e6aac280 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16690,7 +16690,7 @@ uuid-browser@^3.1.0: resolved "https://registry.yarnpkg.com/uuid-browser/-/uuid-browser-3.1.0.tgz#0f05a40aef74f9e5951e20efbf44b11871e56410" integrity sha1-DwWkCu90+eWVHiDvv0SxGHHlZBA= -uuid@8.3.2, uuid@^8.3.0: +uuid@8.3.2, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==