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 { 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 ) +
`<!--${ tag }-->` +
blockContent.slice( end );
let richTextValue = create( { html: blockContent } );
richTextValue = insert(
richTextValue,
create( { html: `<!--${ tag }-->` } ),
start,
end
);
updatedContent = toHTMLString( { value: richTextValue } );
}
updateBlockAttributes( selectedBlockId, {

View File

@ -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 <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.
* 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, <!--[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 (
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 = /^<!--([\s\S]*?)-->$/;
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(
`(?<!<!--)\\[(${ baseToken }(\\s[^\\]]*)?)\\](?!-->)`, // Match full token with optional attributes
`(?<!<!--)(?<!["'])\\[(${ baseToken }(\\s[^\\]]*)?)\\](?!-->)`, // Match token not inside quotes (attributes)
'g'
);
@ -219,10 +178,4 @@ const replacePersonalizationTagsWithHTMLComments = (
return content;
};
export {
isMatchingComment,
getCursorPosition,
createTextToHtmlMap,
mapRichTextToValue,
replacePersonalizationTagsWithHTMLComments,
};
export { getCursorPosition, replacePersonalizationTagsWithHTMLComments };

View File

@ -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 ) +
`<!--${ tagName }-->` +
currentValue.slice( htmlEnd );
let richTextValue = create( { html: currentValue } );
richTextValue = insert(
richTextValue,
create( { html: `<!--${ tagName }-->` } ),
start,
end
);
const updatedValue = toHTMLString( { value: richTextValue } );
// Update the corresponding property
updateEmailMailPoetProperty( attributeName, updatedValue );