UNPKG

@wordpress/editor

Version:
386 lines (343 loc) 10.1 kB
/** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { useState, useEffect, useMemo, useSyncExternalStore, } 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 { getScrollContainer } from '@wordpress/dom'; 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 { createBoardStore } from './board-store'; import { calculateNotePositions } from './utils'; const { cleanEmptyObject } = unlock( blockEditorPrivateApis ); export function useNoteThreads( postId ) { 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 notes to build the tree structure. const { notes, unresolvedNotes } = useMemo( () => { if ( ! threads || threads.length === 0 ) { return { notes: [], unresolvedNotes: [] }; } // Single pass over clientIds: build clientId->noteId map AND reverse lookup. const blocksWithNotes = {}; const clientIdByNoteId = new Map(); for ( const clientId of clientIds ) { const noteId = getBlockAttributes( clientId )?.metadata?.noteId; if ( noteId ) { const key = String( noteId ); blocksWithNotes[ clientId ] = key; clientIdByNoteId.set( key, clientId ); } } // Materialize threads; collect roots; replies linked in a second pass // via unshift to invert order (matches prior reverse semantics). const threadsById = new Map(); const rootThreads = []; for ( const item of threads ) { const thread = { ...item, reply: [], blockClientId: item.parent === 0 ? clientIdByNoteId.get( String( item.id ) ) ?? null : null, }; threadsById.set( item.id, thread ); if ( item.parent === 0 ) { rootThreads.push( thread ); } } for ( const item of threads ) { if ( item.parent !== 0 ) { threadsById .get( item.parent ) ?.reply.unshift( threadsById.get( item.id ) ); } } if ( rootThreads.length === 0 ) { return { notes: [], unresolvedNotes: [] }; } // Single partition over notes-in-block-order. const unresolved = []; const resolved = []; for ( const noteId of Object.values( blocksWithNotes ) ) { const thread = threadsById.get( Number( noteId ) ) ?? threadsById.get( noteId ); if ( ! thread ) { continue; } if ( thread.status === 'hold' ) { unresolved.push( thread ); } else if ( thread.status === 'approved' ) { resolved.push( thread ); } } // Orphans: root threads without a linked block. They only need to come last. const orphans = rootThreads.filter( ( thread ) => ! thread.blockClientId ); return { notes: [ ...unresolved, ...resolved, ...orphans ], unresolvedNotes: unresolved, }; }, [ clientIds, threads, getBlockAttributes ] ); return { notes, unresolvedNotes, }; } export function useNoteActions() { 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 top-level note, update the block attributes with the note 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, } ); return savedRecord; } catch ( error ) { 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 note with the metadata. const newNoteData = { 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', newNoteData, { 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, } ); } catch ( error ) { onError( error ); } }; const onDelete = async ( note ) => { try { await deleteEntityRecord( 'root', 'comment', note.id, undefined, { throwOnError: true, } ); if ( ! note.parent ) { const clientId = getSelectedBlockClientId(); const metadata = getBlockAttributes( clientId )?.metadata; updateBlockAttributes( clientId, { metadata: cleanEmptyObject( { ...metadata, noteId: undefined, } ), } ); } createNotice( 'snackbar', __( 'Note deleted.' ), { type: 'snackbar', isDismissible: true, } ); } catch ( error ) { 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 useFloatingBoard( { threads, selectedNoteId, isFloating, sidebarRef, } ) { const [ notePositions, setNotePositions ] = useState( {} ); const [ store ] = useState( createBoardStore ); const heights = useSyncExternalStore( store.subscribe, store.getSnapshot ); // Notes are positioned in canvas content-space; CSS inherits // `--canvas-scroll` to translate each thread in sync with the canvas. useEffect( () => { if ( ! isFloating || ! sidebarRef?.current ) { return; } const panel = sidebarRef.current; const blockEl = store.getFirstBlockElement(); // Climb to the block-list root so nested scroll containers // (e.g. a Group with overflow:auto) don't shadow the canvas. const rootEl = blockEl?.closest( '.is-root-container' ) ?? blockEl; const canvas = rootEl ? getScrollContainer( rootEl ) : null; const applyScroll = () => { panel.style.setProperty( '--canvas-scroll', `${ -( canvas?.scrollTop ?? 0 ) }px` ); }; // Recalc is deferred to a rAF; back-to-back updates collapse into one paint. const rafId = window.requestAnimationFrame( () => { const result = calculateNotePositions( { threads, selectedNoteId, blockRects: store.getBlockRects(), heights, scrollTop: canvas?.scrollTop ?? 0, } ); setNotePositions( result.positions ); applyScroll(); } ); // Root scrolling elements (documentElement/body) don't fire scroll // on themselves; capture on the window catches them in either canvas. const view = canvas?.ownerDocument?.defaultView; const listenerOptions = { passive: true, capture: true }; view?.addEventListener( 'scroll', applyScroll, listenerOptions ); return () => { window.cancelAnimationFrame( rafId ); view?.removeEventListener( 'scroll', applyScroll, listenerOptions ); }; }, [ sidebarRef, heights, isFloating, selectedNoteId, store, threads ] ); return { notePositions, registerThread: store.registerThread, unregisterThread: store.unregisterThread, }; }