Mentions组件设计
2023-07-25 17:20 前端小白的江湖路 阅读(210) 评论(0) 编辑 收藏 举报什么是Mentions组件
Mentions组件通常用在评论@某人。
功能
1.当输入@提示符时,弹出Dropdown以及触发搜索
2.当存才@name提示符内容,点击选中整个@name,可以使用Backspace一起删除
3.当点击Backspace时,如果遇到@name,将@name作为整体删除
技术选型
目前实现富文本编辑器主要有2种方式,一种是使用canvas,全部内容都通过绘制产生,目前google doc采用canvas渲染。
优点在于渲染性能高,跨浏览器一致性高,但是开发复杂度较高,对于字体整形,分词,基线等知识都有着非常高的要求。
另一种方式是使用contenteditable属性,开发快速,但是缺点也比较明显:坑会比较多,性能较为低下。
本组件场景较为简单,采用contenteditable来实现。
基础API熟悉
在阅读上述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 方法的不同之处
代码实现
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 = "​"; 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 = "​"; 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;