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:
@ -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, {
|
||||
|
@ -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 };
|
||||
|
@ -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 );
|
||||
|
Reference in New Issue
Block a user