代码改变世界

Mentions组件设计

2023-07-25 17:20  前端小白的江湖路  阅读(175)  评论(0编辑  收藏  举报

什么是Mentions组件

 Mentions组件通常用在评论@某人。

功能

1.当输入@提示符时,弹出Dropdown以及触发搜索

2.当存才@name提示符内容,点击选中整个@name,可以使用Backspace一起删除

3.当点击Backspace时,如果遇到@name,将@name作为整体删除

 

技术选型

目前实现富文本编辑器主要有2种方式,一种是使用canvas,全部内容都通过绘制产生,目前google doc采用canvas渲染。

优点在于渲染性能高,跨浏览器一致性高,但是开发复杂度较高,对于字体整形,分词,基线等知识都有着非常高的要求。

另一种方式是使用contenteditable属性,开发快速,但是缺点也比较明显:坑会比较多,性能较为低下。

 本组件场景较为简单,采用contenteditable来实现。

 

基础API熟悉

1.Range & Selection

在阅读上述Mdn文档后,我们有以下的点需要注意

关于Selection

1)如果当前已经有选区,则调用selection.addRange添加新的range会不生效,需要先取消选区

selection.removeRange(range)  // 取消当前选区
selection.removeAllRanges()  // 取消所有选区

 

2)Chrome, Edge 无法在一个Selection中添加多个Range,但是Firefox可以

 

3)Selection.collapse(parentNode, offset)  offset 选项针对是否为文本节点,还是元素节点有不同的表现

解释:Selection.collapse() 方法可以收起当前选区到一个点。文档不会发生改变。如果选区的内容是可编辑的并且焦点落在上面,则光标会在该处闪烁。

parentNode 为Node.Element_Node, 则offset为parentNode.childNodes 数组的偏移量

parentNode 为Node.Text_Node,则offset为文本节点字符偏移量

 

4)Selection.empty 与Selection.removeAllRanges 方法的不同之处

 Selection.empty 老的IE浏览器API,已废弃。取消选区使用removeAllRanges即可
 
5)Selection.getRangeAt(index)
使用这个API前,index需要位于[0, selection.rangeCount) 之间,否则会报错
 
6)创建选区的2种方式
第一种基于DOM操作,使用Selection.selectAllChildren & Selection.setBaseAndExtent 
第二种使用document.createRange() , selection.addRange() 来创建选区,添加选区
 
关于Range
startOffset  & setStart(node, startOffset)
对于文本节点,startOffset 表示字符个数
对于一个非文本节点来说,startOffset 表示子节点数量,考虑这样一个节点div
包含3个子节点p strong span 
startOffset = 0, p 节点开头
startOffset = 1, strong节点开头
startOffset = 2,span节点开头
startOffset=3,span节点结束,也就是下一个节点开头
startOffset > 3, 报错,这里设置的时候,需要判断不大于childNodes.length
 

代码实现

1.准备工作

我们简单创建一个Dropdown组件,用来承接弹出层,这个组件不是实现的重点,直接给出

import React, { useRef, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import cx from "classnames";
import "./index.scss";

interface OptionItem {
  label: string;
  value: string;
}
interface IProps {
  visible: boolean;
  options: Array<OptionItem>;
  left: number;
  top: number;
  onDropdownItemClick: (
    item: OptionItem,
    index: number,
    options: Array<OptionItem>
  ) => void;
}

const Dropdown: React.FC<IProps> = (props) => {
  const { visible, left, top, options, onDropdownItemClick } = props;
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [activeNumber, setActiveNumber] = useState(0);
  const dropdownStyle = {
    left,
    top,
  };

  useEffect(() => {
    const handleKeyDownCallback = (event) => {
      const key = event.key;
      if (!visible) return;
      if (key === "ArrowUp") {
        event.preventDefault(); // 避免光标跳跃
        setActiveNumber(activeNumber - 1 < 0 ? 0 : activeNumber - 1);
      } else if (key === "ArrowDown") {
        event.preventDefault();
        setActiveNumber(
          activeNumber + 1 >= options.length ? activeNumber : activeNumber + 1
        );
      } else if (key === "Enter" && visible) {
        event.preventDefault();
        handleDropdownItemClick(options[activeNumber], activeNumber, options);
      }
    };
    document.addEventListener("keydown", handleKeyDownCallback);

    return () => {
      document.removeEventListener("keydown", handleKeyDownCallback);
    };
  }, [activeNumber, visible]);

  useEffect(() => {
    if (visible) {
      setActiveNumber(0);
    }
  }, [visible]);

  const handleDropdownItemClick = (
    item: OptionItem,
    index: number,
    options: Array<OptionItem>
  ) => {
    typeof onDropdownItemClick === "function" &&
      onDropdownItemClick(item, index, options);
  };

  if (!visible) return null;

  const reactNode = (
    <ul className="i-dropdown" style={dropdownStyle}>
      {options.map((item, index) => {
        return (
          <li
            key={index}
            className={cx("i-dropdown-item", {
              "i-dropdown-item-active": index === activeNumber,
            })}
            onMouseEnter={() => setActiveNumber(index)}
            onClick={() => handleDropdownItemClick(item, index, options)}
          >
            {item.label}
          </li>
        );
      })}
    </ul>
  );

  if (!containerRef.current) {
    const container = document.createElement("div");
    container.className = "i-dropdown-container";
    document.body.appendChild(container);
    containerRef.current = container;
  }
  return ReactDOM.createPortal(reactNode, containerRef.current);
};

export default Dropdown;
.i-dropdown {
  position: fixed;
  left: 0;
  top: 0;
  list-style: none;
  padding: 0;
  margin: 0;
  background-color: white;
  box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
  border-radius: 3px;
  font-size: 14px;
  user-select: none;
  cursor: pointer;
  .i-dropdown-item {
    padding: 4px 12px;

    &-active {
      background-color: rgba(165, 182, 206, 0.2);
    }
  }
}

需要注意的是,在使用键盘的方向键移动时,需要event.preventDefault 来避免光标跳跃到其他焦点

2.创建一个div可编辑元素

 <div className={cx("i-mentions-container", containerClassName)}>
      <div
        className={cx("i-mentions", mentionsClassName)}
        contentEditable
        ref={mentionElementRef}
        onKeyDown={handleKeyDown}
        onKeyUp={handleKeyUp}
      ></div>
      <Dropdown
        visible={isMentionMode}
        options={options}
        onDropdownItemClick={handleDropdownClick}
        left={dropdownPosition.left}
        top={dropdownPosition.top}
      />
    </div>

3.添加事件

这里我们在keydown时候,监听当前键入的字符,在keyUp的时候处理光标相关的操作

keyUp的时候,获取当前的选区,计算光标所在的位置,弹出DropDown

为什么分2步处理,而不是在keydown里面一次处理完成。有以下两点原因

1.时间问题:onkeydown事件在按键按下时触发,但DOM可能尚未更新以反映新的内容或光标位置。因此,在那一刻获得的范围可能不准确。

2.异步行为:getBoundingClientRect()方法是异步的,因为它需要访问渲染引擎来准确计算位置。当立即在onkeydown事件内调用它时,可能没有关于渲染内容的最新信息。

这里我使用了keyUp的事件触发作为处理时机,也可以只监听onInput事件来完成。

你可能想问了,为什么不只监听KeyUp事件,原因在于当你使用组合键时(Shift + 2), KeyUp的触发时机是分步的,得到的event.key 为shift , 2 或者为@

这个就没法监听当前键入的字符了。

 

4.Dropdown选择

1.计算当前光标到@符号的位置

2.创建一个<strong>标签用来替换Dropdown的选择

这里同时注册一个<strong>标签的点击事件,当点击<strong>标签时,创建一个选区,选中当前<strong>标签,删除时,可以一并删除。

  const handleAddRange = (event: MouseEvent) => {
    const target = event.target;
    if (!target) return;
    const selection = document.getSelection();
    const range = document.createRange();
    range.setStart(target, 0);
    range.setEnd(target.nextSibling, target?.nextSibling?.childNodes?.length);
    selection?.removeAllRanges();
    selection?.addRange(range);
  };

 

3.创建一个包含零宽字符<span>元素节点来邻接strong标签

这里是为了避免接下来的输入,自动使用strong标签的样式。原因在于contenteditable会记录你当前使用的样式,自动为你创建font,bold等元素。这个特性会污染我们的样式。

零宽字符(Zero-width characters)是一类不可见的Unicode字符,其宽度为零,也就是在屏幕上不会显示出来。尽管它们在外观上看不见,但它们在文本中起着特殊的作用,如调整文本的布局、格式化和表示信息。

4.创建一个零宽字符文本节点作为接下来的输入承接

这里创建文本节点是为了输入修改空的<span>节点

5.替换完成后,重设当前光标的位置

  const handleDropdownClick = (item: OptionItem) => {
    const range = rangeRef.current;
    if (!range?.startContainer) return;
    const targetNode = range.startContainer;
    const cursorOffset = range.startOffset;
    const nodeValue = targetNode.nodeValue ?? "";
    let targetIndex;
    for (let i = cursorOffset; i >= 0; i--) {
      if (nodeValue?.[i] === "@") {
        targetIndex = i;
        break;
      }
    }
    if (typeof targetIndex === "undefined") return;

    const fragment = document.createDocumentFragment();
    const preTextNode = document.createTextNode(
      nodeValue.slice(0, targetIndex)
    );
    const strongElement = document.createElement("strong");
    strongElement.className = cx("i-mentions-target", highlightClassName);
    strongElement.innerText = `@${item.label}`;
    strongElement.addEventListener("click", handleAddRange);
    const blankSpanNode = document.createElement("span");
    blankSpanNode.innerHTML = "&#8203;";
    blankSpanNode.setAttribute("data-blank-space", "true");
    blankSpanNode.className = "i-mentions-blank-space";

    const blankNode = document.createTextNode("\u200B");
    const postTextNode = document.createTextNode(nodeValue.slice(cursorOffset));
    fragment.appendChild(preTextNode);
    fragment.appendChild(strongElement);
    fragment.appendChild(blankSpanNode);
    fragment.appendChild(blankNode);
    fragment.appendChild(postTextNode);

    if (targetNode.nodeType === Node.TEXT_NODE && targetNode.parentElement) {
      targetNode.parentElement.replaceChild(fragment, targetNode);
    } else {
      targetNode.replaceChild(fragment, targetNode.childNodes[0]);
    }

    // 创建一个只包含空格的元素节点,存放焦点信息
    setCursorToNode(blankNode);
    setIsMentionMode(false);

    typeof onSelect === "function" && onSelect(item);
  };

 

6.删除提及功能

当使用Backspace退格键删除时,需要将整个@name节点删除

上一步我们创建的零宽字符节点作为标记,检测光标前为提及节点

我们在Keyup事件中添加代码

// 删除,当前是空占位节点 & 空占位节点前一个节点是目标节点
if (
  key === BACKSPACE_KEY &&
  checkBlankSpaceNode(startContainer) &&
  checkMentionTarget(previousSibling)
) {
  if (startContainer?.nodeType === Node.TEXT_NODE) {
    startContainer?.parentElement?.remove();
  } else {
    startContainer?.remove();
  }
  previousSibling?.remove();
}

检测节点的代码

  const checkBlankSpaceNode = (node: HTMLElement) => {
    if (node.nodeType === Node.TEXT_NODE && node.parentElement) {
      node = node.parentElement;
    }
    const TARGET_CLASS_NAME = "i-mentions-blank-space";
    if (
      node &&
      node?.className === TARGET_CLASS_NAME &&
      node?.getAttribute("data-blank-space") === "true"
    ) {
      return true;
    }
    return false;
  };

  const checkMentionTarget = (node: HTMLElement) => {
    const TARGET_CLASS_NAME = "i-mentions-target";
    if (
      node &&
      node.nodeType === Node.ELEMENT_NODE &&
      node.tagName === "STRONG" &&
      node.className === TARGET_CLASS_NAME
    ) {
      return true;
    }
    return false;
  };

 

完整的程序清单

import React, {
  useState,
  useEffect,
  useRef,
  useImperativeHandle,
  forwardRef,
} from "react";
import cx from "classnames";
import Dropdown from "../dropdown";
import "./index.scss";

interface OptionItem {
  label: string;
  value: string;
}

interface IProps {
  containerClassName?: string;
  mentionsClassName?: string;
  highlightClassName?: string;
  options: Array<OptionItem>;
  autoFocus?: boolean;
  onSearch?: (value: string) => void;
  onSelect?: (item: OptionItem) => void;
}

export interface MentionsHandler {
  focus: () => void;
  blur: () => void;
}

const Mentions = forwardRef<MentionsHandler, IProps>((props, ref) => {
  const {
    containerClassName,
    mentionsClassName,
    highlightClassName,
    options,
    autoFocus,
    onSearch,
    onSelect,
  } = props;
  const [isMentionMode, setIsMentionMode] = useState<boolean>(false);
  const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0 });
  const mentionElementRef = useRef<HTMLDivElement>(null);
  const rangeRef = useRef<Range>();
  const keyRef = useRef<string>();
  const searchValueRef = useRef<string | null>(null);

  useEffect(() => {
    if (autoFocus) {
      mentionElementRef?.current?.focus();
    }
  }, []);

  useImperativeHandle(ref, () => {
    return {
      focus: () => {
        mentionElementRef?.current?.focus();
      },
      blur: () => {
        mentionElementRef?.current?.blur();
      },
    };
  });

  const handleAddMention = () => {
    if (!rangeRef.current) return;
    const range = rangeRef.current;
    const { left, top, height } = range.getBoundingClientRect();
    setIsMentionMode(true);
    setDropdownPosition({
      left,
      top: top + height,
    });
    typeof onSearch === "function" && onSearch("");
  };

  const setRangeRef = () => {
    const selection = document.getSelection();
    if (!selection) {
      return null;
    }
    const range = selection.getRangeAt(0);
    rangeRef.current = range;
  };

  const handleDropdownClick = (item: OptionItem) => {
    const range = rangeRef.current;
    if (!range?.startContainer) return;
    const targetNode = range.startContainer;
    const cursorOffset = range.startOffset;
    const nodeValue = targetNode.nodeValue ?? "";
    let targetIndex;
    for (let i = cursorOffset; i >= 0; i--) {
      if (nodeValue?.[i] === "@") {
        targetIndex = i;
        break;
      }
    }
    if (typeof targetIndex === "undefined") return;

    const fragment = document.createDocumentFragment();
    const preTextNode = document.createTextNode(
      nodeValue.slice(0, targetIndex)
    );
    const strongElement = document.createElement("strong");
    strongElement.className = cx("i-mentions-target", highlightClassName);
    strongElement.innerText = `@${item.label}`;
    strongElement.addEventListener("click", handleAddRange);
    const blankSpanNode = document.createElement("span");
    blankSpanNode.innerHTML = "&#8203;";
    blankSpanNode.setAttribute("data-blank-space", "true");
    blankSpanNode.className = "i-mentions-blank-space";

    const blankNode = document.createTextNode("\u200B");
    const postTextNode = document.createTextNode(nodeValue.slice(cursorOffset));
    fragment.appendChild(preTextNode);
    fragment.appendChild(strongElement);
    fragment.appendChild(blankSpanNode);
    fragment.appendChild(blankNode);
    fragment.appendChild(postTextNode);

    if (targetNode.nodeType === Node.TEXT_NODE && targetNode.parentElement) {
      targetNode.parentElement.replaceChild(fragment, targetNode);
    } else {
      targetNode.replaceChild(fragment, targetNode.childNodes[0]);
    }

    // 创建一个只包含空格的元素节点,存放焦点信息
    setCursorToNode(blankNode);
    setIsMentionMode(false);

    typeof onSelect === "function" && onSelect(item);
  };

  const setCursorToNode = (targetElement: Node) => {
    const range = document.createRange();
    const selection = window.getSelection();
    range.setStartAfter(targetElement);
    range.setEndAfter(targetElement);
    // 清除之前的选区,并将新选区设置为当前选区
    selection?.removeAllRanges();
    selection?.addRange(range);
  };

  const handleAddRange = (event: MouseEvent) => {
    const target = event.target;
    if (!target) return;
    const selection = document.getSelection();
    const range = document.createRange();
    range.setStart(target, 0);
    range.setEnd(target.nextSibling, target?.nextSibling?.childNodes?.length);
    selection?.removeAllRanges();
    selection?.addRange(range);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const key = event.key;
    keyRef.current = key;
    const BACKSPACE_KEY = "Backspace";
    const ENTER_KEY = "Enter";
    if (key === ENTER_KEY && isMentionMode) {
      event.preventDefault();
    }
    const selection = document.getSelection();
    const range = selection?.getRangeAt(0);
    const startContainer = range?.startContainer;
    if (key === BACKSPACE_KEY && checkMentionTarget(startContainer)) {
      event.preventDefault();
    }
  };

  const handleKeyUp = () => {
    const key = keyRef.current;
    const SPACE_KEY = " ";
    const BACKSPACE_KEY = "Backspace";
    const selection = document.getSelection();
    const range = selection?.getRangeAt(0);
    const startContainer = range?.startContainer;
    const previousSibling = startContainer?.parentElement?.previousSibling;
    const nextSibling = startContainer?.nextSibling;

    // 选中删除,需要重设光标为删除节点之前的位置,否则将保留样式
    if (key === BACKSPACE_KEY && checkMentionTarget(startContainer)) {
      const beforeStartContainer = startContainer?.previousSibling;
      startContainer?.remove();
      if (nextSibling && checkBlankSpaceNode(nextSibling)) {
        nextSibling.remove();
      }
      setCursorToNode(beforeStartContainer);
    }

    // 删除,当前是空占位节点 & 空占位节点前一个节点是目标节点
    if (
      key === BACKSPACE_KEY &&
      checkBlankSpaceNode(startContainer) &&
      checkMentionTarget(previousSibling)
    ) {
      if (startContainer?.nodeType === Node.TEXT_NODE) {
        startContainer?.parentElement?.remove();
      } else {
        startContainer?.remove();
      }
      previousSibling?.remove();
    }

    setRangeRef();
    if (key === "@") {
      handleAddMention();
    }

    if (isMentionMode) {
      if (SPACE_KEY === key) {
        setIsMentionMode(false);
      }
      const searchValue = getSearchValue();
      if (searchValue.startsWith("@")) {
        if (searchValueRef.current !== searchValue && !searchValue) {
          typeof onSearch === "function" && onSearch(searchValue.slice(1));
        }
        searchValueRef.current = searchValue.slice(1);
      } else {
        setIsMentionMode(false);
      }
    }
  };

  const getSearchValue = () => {
    const range = rangeRef.current;
    if (!range || !range?.startContainer) return "";
    const targetNode = range?.startContainer;
    const cursorOffset = range?.startOffset;
    const nodeValue = targetNode.nodeValue ?? "";
    let targetIndex;
    for (let i = cursorOffset; i >= 0; i--) {
      if (nodeValue?.[i] === "@") {
        targetIndex = i;
        break;
      }
    }
    if (typeof targetIndex === "undefined") return "";
    return nodeValue.slice(targetIndex, cursorOffset);
  };

  const checkBlankSpaceNode = (node: HTMLElement) => {
    if (node.nodeType === Node.TEXT_NODE && node.parentElement) {
      node = node.parentElement;
    }
    const TARGET_CLASS_NAME = "i-mentions-blank-space";
    if (
      node &&
      node?.className === TARGET_CLASS_NAME &&
      node?.getAttribute("data-blank-space") === "true"
    ) {
      return true;
    }
    return false;
  };

  const checkMentionTarget = (node: HTMLElement) => {
    const TARGET_CLASS_NAME = "i-mentions-target";
    if (
      node &&
      node.nodeType === Node.ELEMENT_NODE &&
      node.tagName === "STRONG" &&
      node.className === TARGET_CLASS_NAME
    ) {
      return true;
    }
    return false;
  };

  return (
    <div className={cx("i-mentions-container", containerClassName)}>
      <div
        className={cx("i-mentions", mentionsClassName)}
        contentEditable
        ref={mentionElementRef}
        onKeyDown={handleKeyDown}
        onKeyUp={handleKeyUp}
      ></div>
      <Dropdown
        visible={isMentionMode}
        options={options}
        onDropdownItemClick={handleDropdownClick}
        left={dropdownPosition.left}
        top={dropdownPosition.top}
      />
    </div>
  );
});

export default Mentions;