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 133c7534b5..9a26789f74 100644 --- a/packages/js/email-editor/src/blocks/core/rich-text.tsx +++ b/packages/js/email-editor/src/blocks/core/rich-text.tsx @@ -4,6 +4,11 @@ import { BlockControls } from '@wordpress/block-editor'; import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { storeName } from '../../store'; import { useSelect, useDispatch } from '@wordpress/data'; +import { + createTextToHtmlMap, + getCursorPosition, + isMatchingComment, +} from '../../components/personalization-tags/rich-text-utils'; /** * Disable Rich text formats we currently cannot support @@ -21,9 +26,6 @@ function disableCertainRichTextFormats() { } type Props = { - isActive: boolean; - value: string; - onChange: ( value: string ) => void; contentRef: React.RefObject< HTMLElement >; }; @@ -50,163 +52,6 @@ function PersonalizationTagsButton( { contentRef }: Props ) { 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(); @@ -219,27 +64,19 @@ function PersonalizationTagsButton( { contentRef }: Props ) { return; } - // Generate text-to-HTML mapping const { mapping } = createTextToHtmlMap( blockContent ); + let { start, end } = getCursorPosition( contentRef, 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; + if ( ! isMatchingComment( blockContent, start, end ) ) { + start = mapping[ start ] ?? blockContent.length; + end = mapping[ end ] ?? blockContent.length; } const updatedContent = - blockContent.slice( 0, htmlStart ) + + blockContent.slice( 0, start ) + `` + - blockContent.slice( htmlEnd ); + blockContent.slice( end ); updateBlockAttributes( selectedBlockId, { content: updatedContent } ); }; diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.tsx b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.tsx new file mode 100644 index 0000000000..86f5e427eb --- /dev/null +++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.tsx @@ -0,0 +1,191 @@ +import * as React from '@wordpress/element'; + +/** + * Maps HTML indices to corresponding stored value indices in RichText content. + * This function skips over HTML tags, only mapping visible text content. + * + * @param {string} html - The HTML string to map. Example: 'Hello [user/firstname]!' + * @return {number[]} - A mapping array where each HTML index points to its corresponding stored value index. + */ +const mapRichTextToValue = ( html: string ) => { + 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; // Entering an HTML tag + } + if ( htmlChar === '>' ) { + isInsideTag = false; // Exiting an HTML tag + } + mapping[ htmlIndex ] = valueIndex; + if ( ! isInsideTag ) { + valueIndex++; // Increment value index only for visible text + } + + htmlIndex++; + } + + return mapping; +}; + +/** + * Creates a mapping between plain text indices and corresponding HTML indices. + * This includes handling of HTML comments, ensuring text is mapped correctly. + * We need to this step because the text displayed to the user is different from the HTML content. + * + * @param {string} html - The HTML string to map. Example: 'Hello, !' + * @return {{ mapping: number[] }} - An object containing the mapping array. + */ +const createTextToHtmlMap = ( html: string ) => { + 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; // Skip the end of the comment + isInsideComment = false; + } + + text.push( char ); + mapping[ text.length - 1 ] = i; // Map text index to HTML index + } + + // Ensure the mapping includes the end of the content + if ( + mapping.length === 0 || + mapping[ mapping.length - 1 ] !== html.length + ) { + mapping[ text.length ] = html.length; // Map end of content + } + + return { mapping }; +}; + +/** + * Retrieves the cursor position within a RichText component. + * Calculates the offset in plain text while accounting for HTML tags and comments. + * + * @param {React.RefObject} richTextRef - Reference to the RichText component. + * @param {string} content - The plain text content of the block. + * @return {{ start: number, end: number } | null} - The cursor position as start and end offsets. + */ +const getCursorPosition = ( + richTextRef: React.RefObject< HTMLElement >, + content: string +): { start: number; end: number } => { + const selection = + richTextRef.current.ownerDocument.defaultView.getSelection(); + + if ( ! selection.rangeCount ) { + return null; // No selection present + } + + const range = selection.getRangeAt( 0 ); + const container = range.startContainer; + + // 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; // Add text content length of siblings + } + currentNode = currentNode.parentNode; + } + } else { + // Locate the selected content in the HTML + const htmlContent = richTextRef.current.innerHTML; + const selectedText = range.toString(); + + // The selected text is wrapped by span and it is also in the HTML attribute. Adding brackets helps to find the correct index. + // After that we need increase the index by 5 to get the correct index. (4 for start of the HTML comment and 1 for the first bracket) + const startIndex = + htmlContent.indexOf( `>${ selectedText }<`, offset ) + 5; + + // Map the startIndex to the stored value + const mapping = mapRichTextToValue( htmlContent ); + const translatedOffset = mapping[ startIndex ] || 0; + + // Search for HTML comments within the stored value + const htmlCommentRegex = //g; + let match; + let commentStart = -1; + let commentEnd = -1; + + while ( ( match = htmlCommentRegex.exec( content ) ) !== null ) { + const [ fullMatch ] = match; + const matchStartIndex = match.index; + const matchEndIndex = matchStartIndex + fullMatch.length; + + if ( + translatedOffset >= matchStartIndex && + translatedOffset <= matchEndIndex + ) { + commentStart = matchStartIndex; + commentEnd = matchEndIndex; + break; + } + } + + // Return comment range if found + if ( commentStart !== -1 && commentEnd !== -1 ) { + return { start: commentStart, end: commentEnd }; + } + } + + // Default to the current offset if no comment is found + return { + start: offset, + end: offset + range.toString().length, + }; +}; + +/** + * Determines if a given substring within content matches an HTML comment. + * + * @param {string} content - The full content string to search. + * @param {number} start - The start index of the substring. + * @param {number} end - The end index of the substring. + * @return {boolean} - True if the substring matches an HTML comment, otherwise false. + */ +const isMatchingComment = ( + content: string, + start: number, + end: number +): boolean => { + const substring = content.slice( start, end ); + + // Regular expression to match HTML comments + const htmlCommentRegex = /^$/; + + return htmlCommentRegex.test( substring ); +}; + +export { + isMatchingComment, + getCursorPosition, + createTextToHtmlMap, + mapRichTextToValue, +}; diff --git a/packages/js/email-editor/src/components/sidebar/details-panel.tsx b/packages/js/email-editor/src/components/sidebar/details-panel.tsx index 87d1f15862..7f498f60ca 100644 --- a/packages/js/email-editor/src/components/sidebar/details-panel.tsx +++ b/packages/js/email-editor/src/components/sidebar/details-panel.tsx @@ -11,6 +11,11 @@ import { createInterpolateElement, useState, useRef } from '@wordpress/element'; import classnames from 'classnames'; import { storeName } from '../../store'; import { RichText } from '@wordpress/block-editor'; +import { + createTextToHtmlMap, + getCursorPosition, + isMatchingComment, +} from '../personalization-tags/rich-text-utils'; const previewTextMaxLength = 150; const previewTextRecommendedLength = 80; @@ -41,166 +46,6 @@ export function DetailsPanel() { const subjectRef = useRef( null ); const preheaderRef = useRef( null ); - // 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 = - activeRichText === 'subject' - ? mailpoetEmailData?.subject ?? '' - : mailpoetEmailData?.preheader ?? ''; - - // 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 = ( value, start, end ): boolean => { - // Extract the substring - const substring = value.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 handleInsertPersonalizationTag = async ( value ) => { if ( ! activeRichText || ! selectionRange ) { return; @@ -221,9 +66,8 @@ export function DetailsPanel() { const { mapping } = createTextToHtmlMap( currentValue ); // Ensure selection range is within bounds - const maxLength = mapping.length - 1; // Length of plain text - const start = Math.min( selectionRange.start, maxLength ); - const end = Math.min( selectionRange.end, maxLength ); + const start = selectionRange.start; + const end = selectionRange.end; // Default values for starting and ending indexes. let htmlStart = start; @@ -361,15 +205,30 @@ export function DetailsPanel() { ) } onFocus={ () => { setActiveRichText( 'subject' ); - setSelectionRange( getCursorPosition( subjectRef ) ); + setSelectionRange( + getCursorPosition( + subjectRef, + mailpoetEmailData?.subject ?? '' + ) + ); } } onKeyUp={ () => { setActiveRichText( 'subject' ); - setSelectionRange( getCursorPosition( subjectRef ) ); + setSelectionRange( + getCursorPosition( + subjectRef, + mailpoetEmailData?.subject ?? '' + ) + ); } } onClick={ () => { setActiveRichText( 'subject' ); - setSelectionRange( getCursorPosition( subjectRef ) ); + setSelectionRange( + getCursorPosition( + subjectRef, + mailpoetEmailData?.subject ?? '' + ) + ); } } onChange={ ( value ) => updateEmailMailPoetProperty( 'subject', value ) @@ -394,15 +253,30 @@ export function DetailsPanel() { ) } onFocus={ () => { setActiveRichText( 'preheader' ); - setSelectionRange( getCursorPosition( preheaderRef ) ); + setSelectionRange( + getCursorPosition( + preheaderRef, + mailpoetEmailData?.preheader ?? '' + ) + ); } } onKeyUp={ () => { setActiveRichText( 'preheader' ); - setSelectionRange( getCursorPosition( preheaderRef ) ); + setSelectionRange( + getCursorPosition( + preheaderRef, + mailpoetEmailData?.preheader ?? '' + ) + ); } } onClick={ () => { setActiveRichText( 'preheader' ); - setSelectionRange( getCursorPosition( preheaderRef ) ); + setSelectionRange( + getCursorPosition( + preheaderRef, + mailpoetEmailData?.preheader ?? '' + ) + ); } } onChange={ ( value ) => updateEmailMailPoetProperty( 'preheader', value )