From 5a16de1521afdb443d0815da5b9c76a8e50ce65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Lys=C3=BD?= Date: Fri, 20 Dec 2024 20:38:37 +0100 Subject: [PATCH] Refactor work with personalization tags selection The new approach works more with RichText object and looks more stable. [MAILPOET-6394] --- .../src/blocks/core/rich-text.tsx | 32 +-- .../personalization-tags/rich-text-utils.ts | 263 +++++++----------- .../rich-text-with-button.tsx | 27 +- 3 files changed, 125 insertions(+), 197 deletions(-) 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 71bfc1cb57..e03fc1cb18 100644 --- a/packages/js/email-editor/src/blocks/core/rich-text.tsx +++ b/packages/js/email-editor/src/blocks/core/rich-text.tsx @@ -11,9 +11,7 @@ import { BlockControls } from '@wordpress/block-editor'; import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { - createTextToHtmlMap, getCursorPosition, - isMatchingComment, replacePersonalizationTagsWithHTMLComments, } from '../../components/personalization-tags/rich-text-utils'; import { PersonalizationTagsModal } from '../../components/personalization-tags/personalization-tags-modal'; @@ -68,26 +66,8 @@ function PersonalizationTagsButton( { contentRef }: Props ) { const handleInsert = useCallback( ( tag: string, linkText: string | null ) => { - const selection = - contentRef.current.ownerDocument.defaultView.getSelection(); - if ( ! selection ) { - return; - } - - const range = selection.getRangeAt( 0 ); - if ( ! range ) { - return; - } - - const { mapping } = createTextToHtmlMap( blockContent ); let { start, end } = getCursorPosition( contentRef, blockContent ); - // If indexes are not matching a comment, update them - if ( ! isMatchingComment( blockContent, start, end ) ) { - start = mapping[ start ] ?? blockContent.length; - end = mapping[ end ] ?? blockContent.length; - } - let updatedContent = ''; // When we pass linkText, we want to insert the tag as a link if ( linkText ) { @@ -114,10 +94,14 @@ function PersonalizationTagsButton( { contentRef }: Props ) { ); updatedContent = toHTMLString( { value: richTextValue } ); } else { - updatedContent = - blockContent.slice( 0, start ) + - `` + - blockContent.slice( end ); + let richTextValue = create( { html: blockContent } ); + richTextValue = insert( + richTextValue, + create( { html: `` } ), + start, + end + ); + updatedContent = toHTMLString( { value: richTextValue } ); } updateBlockAttributes( selectedBlockId, { diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts index 92cb91598a..2a4f42d95d 100644 --- a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts +++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts @@ -1,81 +1,80 @@ import * as React from '@wordpress/element'; import { PersonalizationTag } from '../../store'; +import { create } from '@wordpress/rich-text'; +import { RichTextFormatList } from '@wordpress/rich-text/build-types/types'; + +function getChildElement( rootElement: HTMLElement ): HTMLElement | null { + let currentElement: HTMLElement | null = rootElement; + + while ( currentElement && currentElement.children.length > 0 ) { + // Traverse into the first child element + currentElement = currentElement.children[ 0 ] as HTMLElement; + } + + return currentElement; +} + +function findReplacementIndex( + element: HTMLElement, + replacements: ( null | { + attributes: Record< string, string >; + type: string; + } )[] +): number | null { + // Iterate over the replacements array + for ( const [ index, replacement ] of replacements.entries() ) { + if ( ! replacement ) { + continue; + } + + const { attributes } = replacement; + + if ( + element.getAttribute( 'data-rich-text-comment' ) === + attributes[ 'data-rich-text-comment' ] + ) { + return index; + } + } + + return null; // Return null if no match is found +} /** - * Maps indices of characters in HTML representation of the value to corresponding characters of stored value in RichText content. The stored value doesn't contain tags. - * 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. + * Find the latest index of the format that matches the element. + * @param element + * @param formats */ -const mapRichTextToValue = ( html: string ) => { - const mapping = []; // Maps HTML indices to stored value indices - let htmlIndex = 0; - let valueIndex = 0; - let isInsideTag = false; +function findLatestFormatIndex( + element: HTMLElement, + formats: RichTextFormatList[] +): number | null { + let latestFormatIndex = null; - 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 + for ( const [ index, formatList ] of formats.entries() ) { + if ( ! formatList ) { + continue; } - htmlIndex++; + // Check each format within the format list at the current index + for ( const format of formatList ) { + if ( + // @ts-expect-error + format?.attributes && + element.tagName.toLowerCase() === + // @ts-expect-error + format.tagName?.toLowerCase() && + element.getAttribute( 'data-link-href' ) === + // @ts-expect-error + format?.attributes[ 'data-link-href' ] + ) { + latestFormatIndex = index; + } + } } - 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 }; -}; + return latestFormatIndex; +} /** * Retrieves the cursor position within a RichText component. @@ -93,98 +92,58 @@ const getCursorPosition = ( richTextRef.current.ownerDocument.defaultView.getSelection(); if ( ! selection.rangeCount ) { - return null; // No selection present + return { + start: 0, + end: 0, + }; } const range = selection.getRangeAt( 0 ); - const container = range.startContainer; - // Ensure the selection is within the RichText component - if ( ! richTextRef.current.contains( container ) ) { - return null; + if ( selection.anchorNode.previousSibling === null ) { + return { + start: selection.anchorOffset, + end: selection.anchorOffset + range.toString().length, + }; } - let offset = range.startOffset; // Initial offset within the current node - let currentNode = container; + const richTextValue = create( { html: content } ); + let previousSibling = selection.anchorNode.previousSibling as HTMLElement; + previousSibling = getChildElement( previousSibling ); - // 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 }; - } + const formatIndex = findLatestFormatIndex( + previousSibling, + richTextValue.formats + ); + if ( formatIndex !== null ) { + return { + start: formatIndex + selection.anchorOffset, + end: formatIndex + selection.anchorOffset + range.toString().length, + }; } - // Default to the current offset if no comment is found + const replacementIndex = findReplacementIndex( + previousSibling, + // @ts-expect-error + richTextValue.replacements + ); + if ( replacementIndex !== null ) { + return { + start: replacementIndex + selection.anchorOffset, + end: + replacementIndex + + selection.anchorOffset + + range.toString().length, + }; + } + + // fallback for placing the value at the end of the rich text return { - start: offset, - end: offset + range.toString().length, + start: richTextValue.text.length, + end: richTextValue.text.length + 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 ); -}; - /** * Replace registered personalization tags with HTML comments in content. * @param content string The content to replace the tags in. @@ -207,7 +166,7 @@ const replacePersonalizationTagsWithHTMLComments = ( .substring( 1, tag.token.length - 1 ) .replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape base token and remove brackets const regex = new RegExp( - `(?)`, // Match full token with optional attributes + `(?)`, // Match token not inside quotes (attributes) 'g' ); @@ -219,10 +178,4 @@ const replacePersonalizationTagsWithHTMLComments = ( return content; }; -export { - isMatchingComment, - getCursorPosition, - createTextToHtmlMap, - mapRichTextToValue, - replacePersonalizationTagsWithHTMLComments, -}; +export { getCursorPosition, replacePersonalizationTagsWithHTMLComments }; diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx b/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx index a99999c966..971dd6e330 100644 --- a/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx +++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx @@ -2,9 +2,7 @@ import { BaseControl, Button } from '@wordpress/components'; import { PersonalizationTagsModal } from './personalization-tags-modal'; import { useCallback, useRef, useState } from '@wordpress/element'; import { - createTextToHtmlMap, getCursorPosition, - isMatchingComment, replacePersonalizationTagsWithHTMLComments, } from './rich-text-utils'; import { useDispatch, useSelect } from '@wordpress/data'; @@ -13,6 +11,7 @@ import { storeName } from '../../store'; import { RichText } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; import { PersonalizationTagsPopover } from './personalization-tags-popover'; +import { create, insert, toHTMLString } from '@wordpress/rich-text'; export function RichTextWithButton( { label, @@ -40,26 +39,18 @@ export function RichTextWithButton( { const handleInsertPersonalizationTag = useCallback( ( tagName, currentValue, currentSelectionRange ) => { - // Generate text-to-HTML mapping - const { mapping } = createTextToHtmlMap( currentValue ); // Ensure selection range is within bounds const start = currentSelectionRange?.start ?? currentValue.length; const end = currentSelectionRange?.end ?? currentValue.length; - // Default values for starting and ending indexes. - let htmlStart = start; - let htmlEnd = end; - // If indexes are not matching a comment, update them - if ( ! isMatchingComment( currentValue, start, end ) ) { - htmlStart = mapping[ start ] ?? currentValue.length; - htmlEnd = mapping[ end ] ?? currentValue.length; - } - - // Insert the new tag - const updatedValue = - currentValue.slice( 0, htmlStart ) + - `` + - currentValue.slice( htmlEnd ); + let richTextValue = create( { html: currentValue } ); + richTextValue = insert( + richTextValue, + create( { html: `` } ), + start, + end + ); + const updatedValue = toHTMLString( { value: richTextValue } ); // Update the corresponding property updateEmailMailPoetProperty( attributeName, updatedValue );