background

It has been almost 10 years since I first used the @person function. I first used it through weibo. @ people function is now all over the application, as long as it involves social, office and other scenarios, is an essential function. Recently ALSO in the IM research of various functions of the implementation of the scheme, so also a little understanding of the @person function of the front-end implementation.

The industry to achieve

weibo

The implementation of weibo is relatively simple, that is, through the regular matching, the end of the match with a space, so the implementation is directly using the Textarea tag. But one thing this implementation must rely on is that the username must be unique. The user name of microblog is unique, so the ID matched by the re can generally be mapped to a unique user (unless the ID does not exist). The overall output is looser, and you can construct any ID that doesn’t exist for the @ operation.

Twitter

The implementation of Twitter is similar to that of weibo, starting with @ and ending with a space. But using the contenteditable property for rich text manipulation is similar in that the Twitter ID is also unique, but can be searched by nickname and converted to ID, which makes the experience much better.

The basic idea

  1. Listen for user input to match user text starting with @.
  2. Call the search popup to display the list of users searched.
  3. Listen for up, down, and Enter keys to control list selection, and listen for ESC keys to close the search popup.
  4. Select the user who needs @ and replace the corresponding HTML text with the original text. Adds the user’s metadata to the HTML text.

Generally, if we use Lark searches, we do not search by unique “job numbers”, but by names, which are repetitive and are less suitable for textarea, replacing @ text with HTML tag specialization tags, as is the case with Contenteditable.

Key steps

  1. Gets the cursor position of the user

To get the string the user entered and replace it, the first step is to get the user’s cursor. To retrieve cursor information, you need to understand “Selection” and “Range”.

Range

A Range is essentially a pair of “boundary points” : the beginning of a Range and the end of a Range.

Each point is represented as a parent DOM node with a relative offset from the starting point. If the parent node is an element node, the offset is the number of the child node, and for a text node, the position in the text.

Such as:

let range = new Range();
Copy the code

Then use range.setstart (node, offset) and range.setend (node, offset) to set the selection boundaries.

Suppose the HTML fragment looks like this:

<p id="p">Example: <i>italic</i> and <b>bold</b></p>
Copy the code

Select “Example: < I >italic
“, which are the first two children of

(text nodes also count) :

<p id="p">Example: <i>italic</i> and <b>bold</b></p>



<script>

  let range = new Range();



  range.setStart(p, 0);

  range.setEnd(p, 2);



  // The toString of the scope returns its contents as text (without labels)

  alert(range); // Example: italic



  document.getSelection().addRange(range);

</script>
Copy the code
  • range.setStart(p, 0)— Set the starting point to<p>The 0th child node of the"Example: ").
  • range.setEnd(p, 2)— Coverage up to (but not included)<p>The second child node of the" and ", but because the last node is not included, the final node is<i>).

If you operate like this:

This can also be done by setting the start and end to relative offsets in the text node.

We need to create a scope:

  • from<p>The first child node starts at position 2 (select “Example:All but the first two letters in the
  • to<b>End (select”bolThe first three letters of d “, that’s all) :
<p id="p">Example: <i>italic</i>  and <b>bold</b></p>



<script>



  let range = new Range();



  range.setStart(p.firstChild, 2);

  range.setEnd(p.querySelector('b').firstChild, 3);



  alert(range); // ample: italic and bol



  window.getSelection().addRange(range);

</script>
Copy the code

The range object has the following properties:

  • StartContainer, startOffset — start node and offset,

    • In the above example, respectively<p>The first text node and2.
  • EndContainer, endOffset — End node and offset,

    • In the above example, respectively<b>The first text node and3.
  • Collapsed — true if ranges start and end at the same point (so there is no content),

    • In the above example:false
  • CommonAncestorContainer — the most recent common ancestor of all nodes in the range,

    • In the above example:<p>

Selection

Range is a generic object for managing selection ranges.

Document Selection is represented by a Selection object, which can be obtained either window.getSelection() or document.getSelection().

A Selection can include zero or more ranges according to the Selection API specification. In practice, however, only Firefox allows you to use Ctrl+ Click (or Cmd+ Click on Mac) to select multiple ranges in a document.

This is a screenshot of a selection made in Firefox with 3 ranges:

Other browsers support a maximum of one range. As we’ll see, some Selection methods imply that there may be multiple ranges, but again, in all browsers except Firefox, the range is at most 1.

Similar to the range, the starting point of the selection is called “anchor” and the end point is called “focus”.

The main selection attributes are:

  • anchorNode— The selected start node,
  • anchorOffset— Choose to startanchorNodeThe offset of,
  • focusNode— The selected end node,
  • focusOffset— Choose where to startfocusNodeThe offset of,
  • isCollapsed— if nothing is selected (empty range) or does not existtrue
  • rangeCount— The number of ranges selected, except for Firefox, is at most1.

After reading the above, DO you know? That’s okay. Let’s keep going. To sum up, we usually have only one Range, but when our cursor flashes over a contenteditable div, we have a Range that starts and ends in the same place. In addition, you can directly obtain the node using Selection. FocusNode and the offset using Selection. FocusOffset. It looks like this:

In this way, we get the cursor position and the corresponding TextNode object.

  1. Get the user who needs @

From step 1, we obtained the offset of the cursor on the corresponding Node and the corresponding Node. Then you can get the entire text through the textContent method.

In general, the @ content can be retrieved with a simple re:

 // Get the cursor position

const getCursorIndex = () = > {

  const selection = window.getSelection();

  returnselection? .focusOffset; };// Get the node

const getRangeNode = () = > {

  const selection = window.getSelection();

  returnselection? .focusNode; };// Get @user

const getAtUser = () = > {

  constcontent = getRangeNode()? .textContent ||"";

  const regx = /@([^@\s]*)$/;

  const match = regx.exec(content.slice(0, getCursorIndex()));

  if (match && match.length === 2) {

    return match[1];

  }

  return undefined;

};
Copy the code

Since the insertion of @ may be the end or the middle, we also need to intercept the text before the cursor before judging.

So simply slice:

content.slice(0, getCursorIndex())
Copy the code
  1. Popup display and key blocking

The logic used to determine whether a popup should display is similar to that used to determine @ users.

 // Whether to display @

const showAt = () = > {

  const node = getRangeNode();

  if(! node || node.nodeType ! == Node.TEXT_NODE)return false;

  const content = node.textContent || "";

  const regx = /@([^@\s]*)$/;

  const match = regx.exec(content.slice(0, getCursorIndex()));

  return match && match.length === 2;

};
Copy the code

Popovers need to be in the right place, but luckily modern browsers have plenty of good apis.

const getRangeRect = () = > {

  const selection = window.getSelection();

  constrange = selection? .getRangeAt(0)! ;const rect = range.getClientRects()[0];

  const LINE_HEIGHT = 30;

  return {

    x: rect.x,

    y: rect.y + LINE_HEIGHT

  };

};
Copy the code

When pop-ups occur, we also need to block the “up”, “down”, and “enter” actions of the input field, otherwise responding to these keys in the input field will shift the cursor position elsewhere.

  const handleKeyDown = (e: any) = > {

    if (showDialog) {

      if (

        e.code === "ArrowUp" ||

        e.code === "ArrowDown" ||

        e.code === "Enter") { e.preventDefault(); }}};Copy the code

Then monitor these keys in the popover to realize the functions of up and down selection, enter confirm, and close the popover.

    const keyDownHandler = (e: any) = > {

      if (visibleRef.current) {

        if (e.code === "Escape") {

          props.onHide();

          return;

        }

        if (e.code === "ArrowDown") {

          setIndex((oldIndex) = > {

            return Math.min(oldIndex + 1, (usersRef.current? .length ||0) - 1);

          });

          return;

        }

        if (e.code === "ArrowUp") {

          setIndex((oldIndex) = > Math.max(0, oldIndex - 1));

          return;

        }

        if (e.code === "Enter") {

          if( indexRef.current ! = =undefined&& usersRef.current? .[indexRef.current] ) { props.onPickUser(usersRef.current? .[indexRef.current]); setIndex(-1);

          }

          return; }}};Copy the code
  1. Replace @ text with custom tags

  1. Cut the original TextNode into chunks

Let’s say the text is, “Please make me a cup of coffee @abc, here’s what comes after.”

So we need to replace the @abc text, depending on where the cursor is, and then split it in two: “Please make me a cup of coffee” and “Here’s what follows.”

  1. Creating an At tag

To enable the delete key to delete all the deletions, you need to wrap the contents of the AT tag. This is a label written in the first edition, but it’s a bit of a problem if you use it directly, so we’ll discuss it later.

const createAtButton = (user: User) = > {

  const btn = document.createElement("span");

  btn.style.display = "inline-block";

  btn.dataset.user = JSON.stringify(user);

  btn.className = "at-button";

  btn.contentEditable = "false";

  btn.textContent = ` @${user.name}`;

  return btn;

};
Copy the code
  1. Insert the label

First, we get the focusNode, and then we get its parent and sibling nodes. All you need to do now is delete the old text node and insert “Please make me a cup of coffee”, [@abc] and “This is what comes after” in its place.

    parentNode.removeChild(oldTextNode);

    // Insert it in the text box

    if (nextNode) {

      parentNode.insertBefore(previousTextNode, nextNode);

      parentNode.insertBefore(atButton, nextNode);

      parentNode.insertBefore(nextTextNode, nextNode);

    } else {

      parentNode.appendChild(previousTextNode);

      parentNode.appendChild(atButton);

      parentNode.appendChild(nextTextNode);

    }
Copy the code
  1. Resets the cursor position

Before we do this, our cursor is missing because the original text node is missing. You need to reposition the cursor after the AT tag. Simply place the cursor before the nextTextNode node.

    // Create a Range and adjust the cursor

    const range = new Range();

    range.setStart(nextTextNode, 0);

    range.setEnd(nextTextNode, 0);

    const selection = window.getSelection(); selection? .removeAllRanges(); selection? .addRange(range);Copy the code
  1. Optimize at tag

In step 2, we created the AT tag, but it was a bit of a problem.

At this point, the cursor is positioned “inside the button frame,” but it is actually in the correct position.

To optimize this problem, the first thing that comes to mind is to add a “0 wide character” to nextTextNode: \u200b


// Add 0 wide characters

const nextTextNode = new Text("\u200b" + restSlice);

// Move the cursor one bit

const range = new Range();

range.setStart(nextTextNode, 1);

range.setEnd(nextTextNode, 1);
Copy the code

But it’s not that simple. Because I realized that if I had gone forward, it would have been the same…

Last thought: Why not make the content area a little wider? Like a space left and right? Then he wrapped the label around it…

const createAtButton = (user: User) = > {

  const btn = document.createElement("span");

  btn.style.display = "inline-block";

  btn.dataset.user = JSON.stringify(user);

  btn.className = "at-button";

  btn.contentEditable = "false";

  btn.textContent = ` @${user.name}`;

  const wrapper = document.createElement("span");

  wrapper.style.display = "inline-block";

  wrapper.contentEditable = "false";

  const spaceElem = document.createElement("span");

  spaceElem.style.whiteSpace = "pre";

  spaceElem.textContent = "\u200b";

  spaceElem.contentEditable = "false";

  const clonedSpaceElem = spaceElem.cloneNode(true);

  wrapper.appendChild(spaceElem);

  wrapper.appendChild(btn);

  wrapper.appendChild(clonedSpaceElem);

  return wrapper;

};
Copy the code

Poor rough version at people, the final end ~

conclusion

Front-end rich text pit is indeed more, before how to understand this part of the knowledge.

The whole process is crude, but the idea is this.

If you are interested, you can also play on the Playground.

There are many imperfections. There are better ways to discuss them together.

Playground

Codesandbox. IO/s/gallant – e…

Standing on the shoulders of giants

Modern JavaScript Tutorial

MDN