@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
386 lines (343 loc) • 10.1 kB
JavaScript
/**
* 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,
};
}