diff --git a/packages/js/email-editor/src/blocks/core/rich-text.tsx b/packages/js/email-editor/src/blocks/core/rich-text.tsx index 519d5ab1b3..133c7534b5 100644 --- a/packages/js/email-editor/src/blocks/core/rich-text.tsx +++ b/packages/js/email-editor/src/blocks/core/rich-text.tsx @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n'; import { BlockControls } from '@wordpress/block-editor'; import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { storeName } from '../../store'; -import { useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; /** * Disable Rich text formats we currently cannot support @@ -20,12 +20,230 @@ function disableCertainRichTextFormats() { unregisterFormatType( 'core/language' ); } +type Props = { + isActive: boolean; + value: string; + onChange: ( value: string ) => void; + contentRef: React.RefObject< HTMLElement >; +}; + /** * A button to the rich text editor to open modal with registered personalization tags. + * + * @param root0 + * @param root0.contentRef */ -function PersonalizationTagsButton() { +function PersonalizationTagsButton( { contentRef }: Props ) { const { togglePersonalizationTagsModal } = useDispatch( storeName ); + const selectedBlockId = useSelect( ( select ) => + select( 'core/block-editor' ).getSelectedBlockClientId() + ); + + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + // Get the current block content + const blockContent: string = useSelect( ( select ) => { + const attributes = + // @ts-ignore + select( 'core/block-editor' ).getBlockAttributes( selectedBlockId ); + return attributes?.content?.originalHTML || attributes?.content || ''; // After first saving the content does not have property originalHTML, so we need to check for content as well + } ); + + // Convert `RichText` DOM offset to stored value offset + const mapRichTextToValue = ( html ) => { + const mapping = []; // Maps HTML indices to stored value indices + let htmlIndex = 0; + let valueIndex = 0; + let isInsideTag = false; + + while ( htmlIndex < html.length ) { + const htmlChar = html[ htmlIndex ]; + if ( htmlChar === '<' ) { + isInsideTag = true; + } + if ( htmlChar === '>' ) { + isInsideTag = false; + } + mapping[ htmlIndex ] = valueIndex; + if ( ! isInsideTag ) { + valueIndex++; + } + + htmlIndex++; + } + + return mapping; + }; + + const createTextToHtmlMap = ( html ) => { + const text = []; + const mapping = []; + let isInsideComment = false; + + for ( let i = 0; i < html.length; i++ ) { + const char = html[ i ]; + + // Detect start of an HTML comment + if ( ! isInsideComment && html.slice( i, i + 4 ) === '' ) { + i += 3; // Adjust loop + isInsideComment = false; + } + + text.push( char ); + mapping[ text.length - 1 ] = i; + } + + // Append mapping for positions between adjacent comments + if ( + mapping.length === 0 || + mapping[ mapping.length - 1 ] !== html.length + ) { + mapping[ text.length ] = html.length; // Map end of content + } + + return { mapping }; + }; + + const getCursorPosition = ( richTextRef ) => { + const selection = + richTextRef.current.ownerDocument.defaultView.getSelection(); + + if ( ! selection.rangeCount ) { + return null; + } + + const range = selection.getRangeAt( 0 ); + const container = range.startContainer; + const currentValue = blockContent; + + // Ensure the selection is within the RichText component + if ( ! richTextRef.current.contains( container ) ) { + return null; + } + + let offset = range.startOffset; // Initial offset within the current node + let currentNode = container; + + // Traverse the DOM tree to calculate the total offset + if ( currentNode !== richTextRef.current ) { + while ( currentNode && currentNode !== richTextRef.current ) { + while ( currentNode.previousSibling ) { + currentNode = currentNode.previousSibling; + offset += currentNode.textContent.length; + } + currentNode = currentNode.parentNode; + } + } else { + // Locate the selected content in the HTML + const htmlContent = richTextRef.current.innerHTML; + const selectedText = range.toString(); + const startIndex = htmlContent.indexOf( selectedText, offset ); + const mapping = mapRichTextToValue( htmlContent ); + + // Translate `offset` from `RichText` HTML to stored value + const translatedOffset = mapping[ startIndex ] || 0; + + // Search for the HTML comment in the stored value + const htmlCommentRegex = //g; + let match; + let commentStart = -1; + let commentEnd = -1; + + while ( + ( match = htmlCommentRegex.exec( currentValue ) ) !== null + ) { + const [ fullMatch ] = match; + const matchStartIndex = match.index; + const matchEndIndex = matchStartIndex + fullMatch.length; + + if ( + translatedOffset >= matchStartIndex && + translatedOffset <= matchEndIndex + ) { + commentStart = matchStartIndex; + commentEnd = matchEndIndex; + break; + } + } + // If a comment is detected, return its range + if ( commentStart !== -1 && commentEnd !== -1 ) { + return { + start: commentStart, + end: commentEnd, + }; + } + } + + return { + start: Math.min( offset, currentValue.length ), + end: Math.min( + offset + range.toString().length, + currentValue.length + ), + }; + }; + + const isMatchingComment = ( content, start, end ): boolean => { + // Extract the substring + const substring = content.slice( start, end ); + + // Define the regex for HTML comments + const htmlCommentRegex = /^$/; + + // Test if the substring matches the regex + const match = htmlCommentRegex.exec( substring ); + + if ( match ) { + return true; + } + + return false; + }; + + const handleInsert = ( tag: string ) => { + const selection = + contentRef.current.ownerDocument.defaultView.getSelection(); + if ( ! selection ) { + return; + } + + const range = selection.getRangeAt( 0 ); + if ( ! range ) { + return; + } + + // Generate text-to-HTML mapping + const { mapping } = createTextToHtmlMap( blockContent ); + + // Ensure selection range is within bounds + const selectionRange = getCursorPosition( contentRef ); + const start = selectionRange.start; + const end = selectionRange.end; + + // Default values for starting and ending indexes. + let htmlStart = start; + let htmlEnd = end; + // If indexes are not matching a comment, update them + if ( ! isMatchingComment( blockContent, htmlStart, htmlEnd ) ) { + htmlStart = mapping[ start ] ?? blockContent.length; + htmlEnd = mapping[ end ] ?? blockContent.length; + } + + const updatedContent = + blockContent.slice( 0, htmlStart ) + + `` + + blockContent.slice( htmlEnd ); + + updateBlockAttributes( selectedBlockId, { content: updatedContent } ); + }; + return ( @@ -33,7 +251,9 @@ function PersonalizationTagsButton() { icon="shortcode" title={ __( 'Personalization Tags', 'mailpoet' ) } onClick={ () => { - togglePersonalizationTagsModal( true ); + togglePersonalizationTagsModal( true, { + onInsert: handleInsert, + } ); } } />