UNPKG

@wordpress/editor

Version:
434 lines (384 loc) 10.9 kB
/** * External dependencies */ import { useFloating, offset as offsetMiddleware, autoUpdate, } from '@floating-ui/react-dom'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { useEffect, useMemo, useCallback, useReducer, } from '@wordpress/element'; import { useEntityRecords, store as coreStore } from '@wordpress/core-data'; import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { store as blockEditorStore, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; import { decodeEntities } from '@wordpress/html-entities'; import { store as interfaceStore } from '@wordpress/interface'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; import { FLOATING_NOTES_SIDEBAR } from './constants'; import { unlock } from '../../lock-unlock'; import { noop } from './utils'; const { useBlockElement, cleanEmptyObject } = unlock( blockEditorPrivateApis ); export function useBlockComments( postId ) { const [ commentLastUpdated, reflowComments ] = useReducer( () => Date.now(), 0 ); const queryArgs = { post: postId, type: 'note', status: 'all', per_page: -1, }; const { records: threads } = useEntityRecords( 'root', 'comment', queryArgs, { enabled: !! postId && typeof postId === 'number' } ); const { getBlockAttributes } = useSelect( blockEditorStore ); const { clientIds } = useSelect( ( select ) => { const { getClientIdsWithDescendants } = select( blockEditorStore ); return { clientIds: getClientIdsWithDescendants(), }; }, [] ); // Process comments to build the tree structure. const { resultComments, unresolvedSortedThreads } = useMemo( () => { if ( ! threads || threads.length === 0 ) { return { resultComments: [], unresolvedSortedThreads: [] }; } const blocksWithComments = clientIds.reduce( ( results, clientId ) => { const commentId = getBlockAttributes( clientId )?.metadata?.noteId; if ( commentId ) { results[ clientId ] = commentId; } return results; }, {} ); // Create a compare to store the references to all objects by id. const compare = {}; const result = []; // Create a reverse map for faster lookup. const commentIdToBlockClientId = Object.keys( blocksWithComments ).reduce( ( mapping, clientId ) => { mapping[ blocksWithComments[ clientId ] ] = clientId; return mapping; }, {} ); // Initialize each object with an empty `reply` array and map blockClientId. threads.forEach( ( item ) => { const itemBlock = commentIdToBlockClientId[ item.id ]; compare[ item.id ] = { ...item, reply: [], blockClientId: item.parent === 0 ? itemBlock : null, }; } ); // Iterate over the data to build the tree structure. threads.forEach( ( item ) => { if ( item.parent === 0 ) { // If parent is 0, it's a root item, push it to the result array. result.push( compare[ item.id ] ); } else if ( compare[ item.parent ] ) { // Otherwise, find its parent and push it to the parent's `reply` array. compare[ item.parent ].reply.push( compare[ item.id ] ); } } ); if ( 0 === result?.length ) { return { resultComments: [], unresolvedSortedThreads: [] }; } const updatedResult = result.map( ( item ) => ( { ...item, reply: [ ...item.reply ].reverse(), } ) ); const threadIdMap = new Map( updatedResult.map( ( thread ) => [ String( thread.id ), thread ] ) ); // Prepare sets to determine which threads are linked to existing blocks. const mappedIds = new Set( Object.values( blocksWithComments ).map( ( id ) => String( id ) ) ); // Get comments by block order, first unresolved, then resolved. const unresolvedSortedComments = Object.values( blocksWithComments ) .map( ( commentId ) => threadIdMap.get( String( commentId ) ) ) .filter( ( thread ) => thread !== undefined && thread.status === 'hold' ); const resolvedSortedComments = Object.values( blocksWithComments ) .map( ( commentId ) => threadIdMap.get( String( commentId ) ) ) .filter( ( thread ) => thread !== undefined && thread.status === 'approved' ); // Append orphaned notes (whose related block was deleted or missing). const orphanedComments = updatedResult.filter( ( thread ) => ! mappedIds.has( String( thread.id ) ) ); const allSortedComments = [ ...unresolvedSortedComments, ...resolvedSortedComments, ...orphanedComments, ]; return { resultComments: allSortedComments, unresolvedSortedThreads: unresolvedSortedComments, }; }, [ clientIds, threads, getBlockAttributes ] ); return { resultComments, unresolvedSortedThreads, reflowComments, commentLastUpdated, }; } export function useBlockCommentsActions( reflowComments = noop ) { const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { getBlockAttributes, getSelectedBlockClientId } = useSelect( blockEditorStore ); const { updateBlockAttributes } = useDispatch( blockEditorStore ); const onError = ( error ) => { const errorMessage = error.message && error.code !== 'unknown_error' ? decodeEntities( error.message ) : __( 'An error occurred while performing an update.' ); createNotice( 'error', errorMessage, { type: 'snackbar', isDismissible: true, } ); }; const onCreate = async ( { content, parent } ) => { try { const savedRecord = await saveEntityRecord( 'root', 'comment', { post: getCurrentPostId(), content, status: 'hold', type: 'note', parent: parent || 0, }, { throwOnError: true } ); // If it's a main comment, update the block attributes with the comment id. if ( ! parent && savedRecord?.id ) { const clientId = getSelectedBlockClientId(); const metadata = getBlockAttributes( clientId )?.metadata; updateBlockAttributes( clientId, { metadata: { ...metadata, noteId: savedRecord.id, }, } ); } createNotice( 'snackbar', parent ? __( 'Reply added.' ) : __( 'Note added.' ), { type: 'snackbar', isDismissible: true, } ); setTimeout( reflowComments, 300 ); return savedRecord; } catch ( error ) { reflowComments(); onError( error ); } }; const onEdit = async ( { id, content, status } ) => { const messageType = status ? status : 'updated'; const messages = { approved: __( 'Note marked as resolved.' ), hold: __( 'Note reopened.' ), updated: __( 'Note updated.' ), }; try { // For resolution or reopen actions, create a new note with metadata. if ( status === 'approved' || status === 'hold' ) { // First, update the thread status. await saveEntityRecord( 'root', 'comment', { id, status, }, { throwOnError: true, } ); // Then create a new comment with the metadata. const newCommentData = { post: getCurrentPostId(), content: content || '', // Empty content for resolve, content for reopen. type: 'note', status, parent: id, meta: { _wp_note_status: status === 'approved' ? 'resolved' : 'reopen', }, }; await saveEntityRecord( 'root', 'comment', newCommentData, { throwOnError: true, } ); } else { const updateData = { id, content, status, }; await saveEntityRecord( 'root', 'comment', updateData, { throwOnError: true, } ); } createNotice( 'snackbar', messages[ messageType ] ?? __( 'Note updated.' ), { type: 'snackbar', isDismissible: true, } ); reflowComments(); } catch ( error ) { reflowComments(); onError( error ); } }; const onDelete = async ( comment ) => { try { await deleteEntityRecord( 'root', 'comment', comment.id, undefined, { throwOnError: true, } ); if ( ! comment.parent ) { const clientId = getSelectedBlockClientId(); const metadata = getBlockAttributes( clientId )?.metadata; updateBlockAttributes( clientId, { metadata: cleanEmptyObject( { ...metadata, noteId: undefined, } ), } ); } createNotice( 'snackbar', __( 'Note deleted.' ), { type: 'snackbar', isDismissible: true, } ); reflowComments(); } catch ( error ) { reflowComments(); onError( error ); } }; return { onCreate, onEdit, onDelete }; } export function useEnableFloatingSidebar( enabled = false ) { const registry = useRegistry(); useEffect( () => { if ( ! enabled ) { return; } const { getActiveComplementaryArea } = registry.select( interfaceStore ); const { disableComplementaryArea, enableComplementaryArea } = registry.dispatch( interfaceStore ); const unsubscribe = registry.subscribe( () => { // Return `null` to indicate the user hid the complementary area. if ( getActiveComplementaryArea( 'core' ) === null ) { enableComplementaryArea( 'core', FLOATING_NOTES_SIDEBAR ); } } ); return () => { unsubscribe(); if ( getActiveComplementaryArea( 'core' ) === FLOATING_NOTES_SIDEBAR ) { disableComplementaryArea( 'core', FLOATING_NOTES_SIDEBAR ); } }; }, [ enabled, registry ] ); } export function useFloatingThread( { thread, calculatedOffset, setHeights, selectedThread, setBlockRef, commentLastUpdated, } ) { const blockElement = useBlockElement( thread.blockClientId ); const updateHeight = useCallback( ( id, newHeight ) => { setHeights( ( prev ) => { if ( prev[ id ] !== newHeight ) { return { ...prev, [ id ]: newHeight }; } return prev; } ); }, [ setHeights ] ); // Use floating-ui to track the block element's position with the calculated offset. const { y, refs } = useFloating( { placement: 'right-start', middleware: [ offsetMiddleware( { crossAxis: calculatedOffset || -16, } ), ], whileElementsMounted: autoUpdate, } ); // Store the block reference for each thread. useEffect( () => { if ( blockElement ) { refs.setReference( blockElement ); } }, [ blockElement, refs, commentLastUpdated ] ); // Track thread heights. useEffect( () => { if ( refs.floating?.current ) { setBlockRef( thread.id, blockElement ); } }, [ blockElement, thread.id, refs.floating, setBlockRef ] ); // When the selected thread changes, update heights, triggering offset recalculation. useEffect( () => { if ( refs.floating?.current ) { const newHeight = refs.floating.current.scrollHeight; updateHeight( thread.id, newHeight ); } }, [ thread.id, updateHeight, refs.floating, selectedThread, commentLastUpdated, ] ); return { y, refs, }; }