Skip to content

Commit

Permalink
Merge pull request #225 from 10up/fix/caret-reversal
Browse files Browse the repository at this point in the history
fix/caret: Fix caret and formatting issue
  • Loading branch information
Sidsector9 authored Nov 7, 2023
2 parents e4cb145 + 3dbb952 commit 302fece
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 44 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ module.exports = {
"tenupIscAdminVars": "readonly",
"localStorage": "readonly",
"jQuery": "readonly",
"Node": "readonly",
}
};
163 changes: 119 additions & 44 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { registerFormatType, toggleFormat, insert } from '@wordpress/rich-text';
import { Fragment } from '@wordpress/element';
import {
registerFormatType,
create,
insert,
applyFormat,
getActiveFormats,
} from '@wordpress/rich-text';
import {
Fragment,
useState,
useRef,
useEffect,
useMemo,
} from '@wordpress/element';
import { BlockControls, RichTextShortcut } from '@wordpress/block-editor';
import { Popover, ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { applyFilters } from '@wordpress/hooks';
Expand All @@ -21,8 +33,6 @@ const InsertSpecialCharactersOptions = {

const { name, title, character } = InsertSpecialCharactersOptions;
const type = `special-characters/${ name }`;
let anchorRange;
let anchorRect;

/**
* Register the "Format Type" to create the character inserter.
Expand All @@ -37,67 +47,130 @@ registerFormatType( type, {
* The `edit` function is called when the Character Map is selected.
*
* @param {Object} props Props object.
* @param {boolean} props.isActive State of popover.
* @param {boolean} props.value State of popover.
* @param {Function} props.onChange Event handler to detect range selection.
* @param {HTMLElement} props.contentRef The editable element.
*/
edit( { isActive, value, onChange, contentRef } ) {
const onToggle = () => {
const selection = contentRef.current.ownerDocument.getSelection();
edit: function Edit( { value, onChange, contentRef } ) {
const [ isPopoverActive, setIsPopoverActive ] = useState( false );
const popoverRef = useRef( null );
const { start, end } = value;

anchorRange =
selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null;
function insertCharacter( char ) {
let richTextCharacter = create( {
text: char,
} );

// Pin the Popover to the caret position.
const boundingClientRect = anchorRange
? anchorRange.getBoundingClientRect()
: null;
for ( const format of getActiveFormats( value ) ) {
richTextCharacter = applyFormat(
richTextCharacter,
format,
0,
1
);
}

const modified = insert( value, richTextCharacter, start, end );

onChange( modified );
}

/**
* Find the text node and its offset within the provided element based on an index.
*
* @param {Node} node The root node to search for the index.
* @param {number} index The index within the text content.
* @return {Array|null} An array containing the text node and its offset, or null if not found.
*/
function findTextNodeAtIndex( node, index ) {
let currentOffset = 0;

/**
* Recursively traverse DOM to find the text node and offset.
*
* @param {Node} __node The current node.
* @return {Array|null} Array containing the text node and its offset, or null if not found.
*/
function traverseDOM( __node ) {
if ( __node.nodeType === Node.TEXT_NODE ) {
const textLength = __node.textContent.length;

if ( currentOffset + textLength >= index ) {
return [ __node, index - currentOffset ];
}

currentOffset += textLength;
} else {
for ( const childNode of __node.childNodes ) {
const result = traverseDOM( childNode );

if ( result ) {
return result;
}
}
}
}

return traverseDOM( node );
}

const memoizedPopoverRef = useMemo( () => popoverRef, [] );

useEffect( () => {
const fauxCursor = document.createElement( 'span' );

if (
contentRef.current &&
memoizedPopoverRef.current &&
isPopoverActive
) {
fauxCursor.className = 'insert-special-character__faux-caret';

const range = document.createRange();
const [ textNode, offsetWithinText ] = findTextNodeAtIndex(
contentRef.current,
start
);

if ( textNode ) {
range.setStart( textNode, offsetWithinText );
range.collapse( true );
range.insertNode( fauxCursor );
range.setStartAfter( fauxCursor );
range.collapse( true );
}
}

return () => {
if ( fauxCursor ) {
fauxCursor.remove();
}
};
}, [ isPopoverActive, memoizedPopoverRef ] );

anchorRect = anchorRange ? () => boundingClientRect : null;
onChange( toggleFormat( value, { type } ) );
};
const characters = applyFilters( `${ name }-characters`, Chars );
// Display the character map when it is active.
const specialCharsPopover = isActive && (
const specialCharsPopover = isPopoverActive && (
<Popover
className="character-map-popover"
placement="bottom-start"
focusOnMount="firstElement"
key="charmap-popover"
getAnchorRect={ anchorRect }
anchor={ contentRef.current }
expandOnMobile={ true }
headerTitle={ __(
'Insert Special Character',
'insert-special-characters'
) }
onClose={ () => {
onChange( toggleFormat( value, { type } ) );
} }
ref={ popoverRef }
>
<CharacterMap
characterData={ characters }
onSelect={
// Insert the selected character and close the popover.
( char ) => {
const newValue = {
...value,
// grab the format at the start position,
// if it is undefined then use an empty array.
formats: value.formats.at( value.start )
? [ value.formats.at( value.start ) ]
: [],
text: char.char,
};

onChange(
insert(
value,
newValue,
newValue.start,
newValue.end
)
);
( obj ) => {
insertCharacter( obj.char );
setIsPopoverActive( false );
}
}
categoryNames={ {
Expand Down Expand Up @@ -142,9 +215,11 @@ registerFormatType( type, {
<ToolbarButton
className={ `toolbar-button-with-text toolbar-button__advanced-${ name }` }
icon="editor-customchar"
isPressed={ isActive }
isPressed={ isPopoverActive }
label={ title }
onClick={ onToggle }
onClick={ () =>
setIsPopoverActive( ! isPopoverActive )
}
shortcut={ displayShortcut.primary( character ) }
/>
</ToolbarGroup>
Expand All @@ -153,7 +228,7 @@ registerFormatType( type, {
<RichTextShortcut
type="primary"
character={ character }
onUse={ onToggle }
onUse={ () => setIsPopoverActive( ! isPopoverActive ) }
/>
</Fragment>
{ specialCharsPopover }
Expand Down
19 changes: 19 additions & 0 deletions src/insert-special-characters.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
$grid-border-color: #c8c8c8;

.insert-special-character__faux-caret {
border-left: 1px solid #000;
animation: 1s caret-blink step-end infinite;
margin-left: -1px;
}

.insert-special-character__faux-selection {
background-color: rgba(0, 0, 0, 0.2);
}

@keyframes caret-blink {
from, to {
border-color: black;
}
50% {
border-color: transparent;
}
}

.character-map-popover {
.components-popover__content {
border: 1px solid #1e1e1e
Expand Down

0 comments on commit 302fece

Please sign in to comment.