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