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 )