Refactor work with personalization tags selection

The new approach works more with RichText object and looks more stable.
[MAILPOET-6394]
This commit is contained in:
Jan Lysý
2024-12-20 20:38:37 +01:00
committed by Aschepikov
parent 3f6358a2fe
commit 5a16de1521
3 changed files with 125 additions and 197 deletions

View File

@ -11,9 +11,7 @@ import { BlockControls } from '@wordpress/block-editor';
import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data'; import { useSelect, useDispatch } from '@wordpress/data';
import { import {
createTextToHtmlMap,
getCursorPosition, getCursorPosition,
isMatchingComment,
replacePersonalizationTagsWithHTMLComments, replacePersonalizationTagsWithHTMLComments,
} from '../../components/personalization-tags/rich-text-utils'; } from '../../components/personalization-tags/rich-text-utils';
import { PersonalizationTagsModal } from '../../components/personalization-tags/personalization-tags-modal'; import { PersonalizationTagsModal } from '../../components/personalization-tags/personalization-tags-modal';
@ -68,26 +66,8 @@ function PersonalizationTagsButton( { contentRef }: Props ) {
const handleInsert = useCallback( const handleInsert = useCallback(
( tag: string, linkText: string | null ) => { ( 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 ); 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 = ''; let updatedContent = '';
// When we pass linkText, we want to insert the tag as a link // When we pass linkText, we want to insert the tag as a link
if ( linkText ) { if ( linkText ) {
@ -114,10 +94,14 @@ function PersonalizationTagsButton( { contentRef }: Props ) {
); );
updatedContent = toHTMLString( { value: richTextValue } ); updatedContent = toHTMLString( { value: richTextValue } );
} else { } else {
updatedContent = let richTextValue = create( { html: blockContent } );
blockContent.slice( 0, start ) + richTextValue = insert(
`<!--${ tag }-->` + richTextValue,
blockContent.slice( end ); create( { html: `<!--${ tag }-->` } ),
start,
end
);
updatedContent = toHTMLString( { value: richTextValue } );
} }
updateBlockAttributes( selectedBlockId, { updateBlockAttributes( selectedBlockId, {

View File

@ -1,81 +1,80 @@
import * as React from '@wordpress/element'; import * as React from '@wordpress/element';
import { PersonalizationTag } from '../../store'; 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 {
* 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. let currentElement: HTMLElement | null = rootElement;
* This function skips over HTML tags, only mapping visible text content.
*
*
* @param {string} html - The HTML string to map. Example: 'Hello <span contenteditable="false" data-rich-text-comment="[user/firstname]"><span>[user/firstname]</span></span>!'
* @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 ) { while ( currentElement && currentElement.children.length > 0 ) {
const htmlChar = html[ htmlIndex ]; // Traverse into the first child element
if ( htmlChar === '<' ) { currentElement = currentElement.children[ 0 ] as HTMLElement;
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 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;
} }
return mapping; const { attributes } = replacement;
};
/**
* 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, <!--[user/firstname]-->!'
* @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 += 4; // Skip the start of the comment
isInsideComment = true;
}
// Detect end of an HTML comment
if ( isInsideComment && html.slice( i, i + 3 ) === '-->' ) {
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 ( if (
mapping.length === 0 || element.getAttribute( 'data-rich-text-comment' ) ===
mapping[ mapping.length - 1 ] !== html.length attributes[ 'data-rich-text-comment' ]
) { ) {
mapping[ text.length ] = html.length; // Map end of content return index;
}
} }
return { mapping }; return null; // Return null if no match is found
}; }
/**
* Find the latest index of the format that matches the element.
* @param element
* @param formats
*/
function findLatestFormatIndex(
element: HTMLElement,
formats: RichTextFormatList[]
): number | null {
let latestFormatIndex = null;
for ( const [ index, formatList ] of formats.entries() ) {
if ( ! formatList ) {
continue;
}
// 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 latestFormatIndex;
}
/** /**
* Retrieves the cursor position within a RichText component. * Retrieves the cursor position within a RichText component.
@ -93,96 +92,56 @@ const getCursorPosition = (
richTextRef.current.ownerDocument.defaultView.getSelection(); richTextRef.current.ownerDocument.defaultView.getSelection();
if ( ! selection.rangeCount ) { if ( ! selection.rangeCount ) {
return null; // No selection present return {
start: 0,
end: 0,
};
} }
const range = selection.getRangeAt( 0 ); const range = selection.getRangeAt( 0 );
const container = range.startContainer;
// Ensure the selection is within the RichText component if ( selection.anchorNode.previousSibling === null ) {
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 { return {
start: offset, start: selection.anchorOffset,
end: offset + range.toString().length, end: selection.anchorOffset + range.toString().length,
}; };
}; }
/** const richTextValue = create( { html: content } );
* Determines if a given substring within content matches an HTML comment. let previousSibling = selection.anchorNode.previousSibling as HTMLElement;
* previousSibling = getChildElement( previousSibling );
* @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 formatIndex = findLatestFormatIndex(
const htmlCommentRegex = /^<!--([\s\S]*?)-->$/; previousSibling,
richTextValue.formats
);
if ( formatIndex !== null ) {
return {
start: formatIndex + selection.anchorOffset,
end: formatIndex + selection.anchorOffset + range.toString().length,
};
}
return htmlCommentRegex.test( substring ); 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: richTextValue.text.length,
end: richTextValue.text.length + range.toString().length,
};
}; };
/** /**
@ -207,7 +166,7 @@ const replacePersonalizationTagsWithHTMLComments = (
.substring( 1, tag.token.length - 1 ) .substring( 1, tag.token.length - 1 )
.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape base token and remove brackets .replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape base token and remove brackets
const regex = new RegExp( const regex = new RegExp(
`(?<!<!--)\\[(${ baseToken }(\\s[^\\]]*)?)\\](?!-->)`, // Match full token with optional attributes `(?<!<!--)(?<!["'])\\[(${ baseToken }(\\s[^\\]]*)?)\\](?!-->)`, // Match token not inside quotes (attributes)
'g' 'g'
); );
@ -219,10 +178,4 @@ const replacePersonalizationTagsWithHTMLComments = (
return content; return content;
}; };
export { export { getCursorPosition, replacePersonalizationTagsWithHTMLComments };
isMatchingComment,
getCursorPosition,
createTextToHtmlMap,
mapRichTextToValue,
replacePersonalizationTagsWithHTMLComments,
};

View File

@ -2,9 +2,7 @@ import { BaseControl, Button } from '@wordpress/components';
import { PersonalizationTagsModal } from './personalization-tags-modal'; import { PersonalizationTagsModal } from './personalization-tags-modal';
import { useCallback, useRef, useState } from '@wordpress/element'; import { useCallback, useRef, useState } from '@wordpress/element';
import { import {
createTextToHtmlMap,
getCursorPosition, getCursorPosition,
isMatchingComment,
replacePersonalizationTagsWithHTMLComments, replacePersonalizationTagsWithHTMLComments,
} from './rich-text-utils'; } from './rich-text-utils';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
@ -13,6 +11,7 @@ import { storeName } from '../../store';
import { RichText } from '@wordpress/block-editor'; import { RichText } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { PersonalizationTagsPopover } from './personalization-tags-popover'; import { PersonalizationTagsPopover } from './personalization-tags-popover';
import { create, insert, toHTMLString } from '@wordpress/rich-text';
export function RichTextWithButton( { export function RichTextWithButton( {
label, label,
@ -40,26 +39,18 @@ export function RichTextWithButton( {
const handleInsertPersonalizationTag = useCallback( const handleInsertPersonalizationTag = useCallback(
( tagName, currentValue, currentSelectionRange ) => { ( tagName, currentValue, currentSelectionRange ) => {
// Generate text-to-HTML mapping
const { mapping } = createTextToHtmlMap( currentValue );
// Ensure selection range is within bounds // Ensure selection range is within bounds
const start = currentSelectionRange?.start ?? currentValue.length; const start = currentSelectionRange?.start ?? currentValue.length;
const end = currentSelectionRange?.end ?? currentValue.length; const end = currentSelectionRange?.end ?? currentValue.length;
// Default values for starting and ending indexes. let richTextValue = create( { html: currentValue } );
let htmlStart = start; richTextValue = insert(
let htmlEnd = end; richTextValue,
// If indexes are not matching a comment, update them create( { html: `<!--${ tagName }-->` } ),
if ( ! isMatchingComment( currentValue, start, end ) ) { start,
htmlStart = mapping[ start ] ?? currentValue.length; end
htmlEnd = mapping[ end ] ?? currentValue.length; );
} const updatedValue = toHTMLString( { value: richTextValue } );
// Insert the new tag
const updatedValue =
currentValue.slice( 0, htmlStart ) +
`<!--${ tagName }-->` +
currentValue.slice( htmlEnd );
// Update the corresponding property // Update the corresponding property
updateEmailMailPoetProperty( attributeName, updatedValue ); updateEmailMailPoetProperty( attributeName, updatedValue );