UNPKG

@wordpress/block-library

Version:
338 lines (293 loc) 9.48 kB
/** * WordPress dependencies */ import { store as coreStore } from '@wordpress/core-data'; import { privateApis as blockEditorPrivateApis, store as blockEditorStore, } from '@wordpress/block-editor'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { useRegistry, useSelect } from '@wordpress/data'; import { useCallback, useEffect, useRef } from '@wordpress/element'; /** * Internal dependencies */ import { unlock } from '../lock-unlock'; function normalizeImageBlockCaption( caption ) { if ( typeof caption !== 'string' ) { return ''; } const textContent = stripHTML( caption ).trim(); if ( ! textContent ) { return ''; } return caption.replace( /\n/g, '<br>' ); } function getAttachmentCaption( attachment ) { const caption = attachment?.caption; if ( typeof caption === 'string' ) { return normalizeImageBlockCaption( caption ); } if ( caption && typeof caption === 'object' && Object.hasOwn( caption, 'raw' ) ) { return normalizeImageBlockCaption( caption.raw ); } return undefined; } export function getImageBlockMetadataFromAttachment( attachment ) { return { alt: typeof attachment?.alt_text === 'string' ? attachment.alt_text : attachment?.alt || '', caption: getAttachmentCaption( attachment ), }; } function normalizeMetadataAttribute( value ) { return value || ''; } export function getSyncedImageBlockAttributes( currentAttributes, originalAttachment, updatedAttachment ) { if ( ! originalAttachment || ! updatedAttachment ) { return {}; } const originalMetadata = getImageBlockMetadataFromAttachment( originalAttachment ); const updatedMetadata = getImageBlockMetadataFromAttachment( updatedAttachment ); const syncedAttributes = {}; const normalizedCurrentAlt = normalizeMetadataAttribute( currentAttributes.alt ); if ( originalMetadata.alt !== updatedMetadata.alt && ( normalizedCurrentAlt === originalMetadata.alt || ! normalizedCurrentAlt ) ) { syncedAttributes.alt = updatedMetadata.alt; } const normalizedCurrentCaption = normalizeMetadataAttribute( currentAttributes.caption ); if ( originalMetadata.caption !== undefined && updatedMetadata.caption !== undefined && originalMetadata.caption !== updatedMetadata.caption && ( normalizedCurrentCaption === originalMetadata.caption || ! normalizedCurrentCaption ) ) { syncedAttributes.caption = updatedMetadata.caption || undefined; } return syncedAttributes; } const { openMediaEditorModalKey } = unlock( blockEditorPrivateApis ); // Caption sync needs `caption.raw`; view/default attachment records can contain // only rendered caption data or be tied to an in-flight stale resolution. const ATTACHMENT_EDIT_QUERY = { context: 'edit' }; function getAttachmentFallbackForEmptyBlockMetadata( { alt, caption } ) { const attachment = {}; if ( ! alt ) { attachment.alt_text = ''; } if ( ! caption?.toString() ) { attachment.caption = ''; } return Object.keys( attachment ).length ? attachment : undefined; } function hasKnownAttachmentMetadata( attachment ) { if ( ! attachment ) { return false; } const hasKnownAlt = typeof attachment.alt_text === 'string' || typeof attachment.alt === 'string'; const hasKnownCaption = getImageBlockMetadataFromAttachment( attachment ).caption !== undefined; return hasKnownAlt && hasKnownCaption; } export function useOpenImageMediaEditorModal( { attributes, setAttributes } ) { // Keep this hook private to the Image block and pass the block attributes // object so the callsite stays compact. Destructure only the attributes // currently used for metadata sync; add more here if the sync policy grows. const { id, url, alt, caption } = attributes; const registry = useRegistry(); const openMediaEditorModal = useSelect( ( select ) => select( blockEditorStore ).getSettings()[ openMediaEditorModalKey ], [] ); // Track the block's current alt and caption in a ref so handleMediaUpdate // can read the latest values without being listed as a dependency (which // would recreate the callback and re-register the onUpdate handler on every // keystroke while the modal is open). const blockMetadataRef = useRef( { alt, caption: caption?.toString() } ); // Snapshot of the attachment's metadata taken just before the modal opens, // used as the baseline for detecting what changed during the editing session. const mediaEditorMetadataBaselineRef = useRef(); // Incremented on every handleMediaUpdate call; stale async continuations // check against this to bail out if a newer update has since started. const mediaEditorMetadataSyncRequestRef = useRef( 0 ); useEffect( () => { blockMetadataRef.current = { alt, caption: caption?.toString() }; }, [ alt, caption ] ); const getCachedAttachmentRecord = useCallback( ( attachmentId ) => { const { getEditedEntityRecord, getEntityRecord } = registry.select( coreStore ); return ( getEditedEntityRecord( 'postType', 'attachment', attachmentId ) || getEntityRecord( 'postType', 'attachment', attachmentId, ATTACHMENT_EDIT_QUERY ) || getEntityRecord( 'postType', 'attachment', attachmentId ) ); }, [ registry ] ); const resolveAttachmentRecord = useCallback( async ( attachmentId ) => { const resolveSelect = registry.resolveSelect( coreStore ); try { return ( ( await resolveSelect.getEntityRecord( 'postType', 'attachment', attachmentId, ATTACHMENT_EDIT_QUERY ) ) || ( await resolveSelect.getEntityRecord( 'postType', 'attachment', attachmentId ) ) ); } catch { return undefined; } }, [ registry ] ); const resolveFreshAttachmentRecord = useCallback( async ( attachmentId ) => { // Bust cached records so resolveAttachmentRecord fetches the // server state that reflects the media editor's saved changes. const { invalidateResolution } = registry.dispatch( coreStore ); invalidateResolution( 'getEntityRecord', [ 'postType', 'attachment', attachmentId, ] ); invalidateResolution( 'getEntityRecord', [ 'postType', 'attachment', attachmentId, ATTACHMENT_EDIT_QUERY, ] ); return resolveAttachmentRecord( attachmentId ); }, [ registry, resolveAttachmentRecord ] ); const handleMediaUpdate = useCallback( async ( { id: newId, url: newUrl } ) => { if ( typeof newId !== 'number' ) { return; } // Capture and clear the baseline so a rapid second save doesn't // reuse a stale snapshot. const originalAttachment = mediaEditorMetadataBaselineRef.current; mediaEditorMetadataBaselineRef.current = undefined; const syncRequest = ++mediaEditorMetadataSyncRequestRef.current; const nextAttributes = {}; if ( newId !== id ) { nextAttributes.id = newId; nextAttributes.url = newUrl ?? url; } if ( originalAttachment ) { // Fetch fresh server state so the comparison reflects what // the media editor actually saved, not a potentially stale // cache. const resolvedAttachment = await resolveFreshAttachmentRecord( newId ); // A newer update started while we were awaiting; discard // this one. if ( syncRequest !== mediaEditorMetadataSyncRequestRef.current ) { return; } // Sync alt text and caption back to the block only when // they were changed in the media editor. Fields the user // has independently customised on the block (i.e. values // that don't match the pre-session attachment metadata) // are left untouched. const resolvedMetadataAttributes = getSyncedImageBlockAttributes( blockMetadataRef.current, originalAttachment, resolvedAttachment ); if ( Object.keys( resolvedMetadataAttributes ).length ) { Object.assign( nextAttributes, resolvedMetadataAttributes ); blockMetadataRef.current = { ...blockMetadataRef.current, ...resolvedMetadataAttributes, }; } } if ( Object.keys( nextAttributes ).length ) { setAttributes( nextAttributes ); } }, [ id, resolveFreshAttachmentRecord, setAttributes, url ] ); const openImageMediaEditorModal = useCallback( async () => { if ( ! id || ! openMediaEditorModal ) { return; } // Snapshot the attachment's current metadata before the user makes // any changes so handleMediaUpdate can compare against it later. // Prefer a freshly resolved edit-context record for accuracy; fall // back to whatever is in the cache, or a minimal object derived from // the block's own attributes when nothing is cached yet. const cachedAttachmentRecord = getCachedAttachmentRecord( id ); const fallbackAttachmentRecord = getAttachmentFallbackForEmptyBlockMetadata( blockMetadataRef.current ); const resolvedAttachmentRecord = hasKnownAttachmentMetadata( cachedAttachmentRecord ) ? undefined : await resolveAttachmentRecord( id ); mediaEditorMetadataBaselineRef.current = resolvedAttachmentRecord || ( hasKnownAttachmentMetadata( cachedAttachmentRecord ) ? cachedAttachmentRecord : fallbackAttachmentRecord ) || cachedAttachmentRecord; openMediaEditorModal( { id, onUpdate: handleMediaUpdate, } ); }, [ getCachedAttachmentRecord, handleMediaUpdate, id, openMediaEditorModal, resolveAttachmentRecord, ] ); return id && openMediaEditorModal ? openImageMediaEditorModal : undefined; }