記錄---實現一個支援@的輸入框

林恒發表於2024-06-28

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

近期產品期望在後臺釋出帖子或影片時,需要新增 @使用者 的功能,以便使用者收到通知,例如“xxx在xxx提及了您!”。然而,現有的開源庫未能滿足我們的需求,例如 ant-design 的 Mentions 元件:

但是不難發現跟微信飛書對比下,有兩個細節沒有處理。

  1. @使用者沒有高亮
  2. 在刪除時沒有當做一個整體去刪除,而是單個字母刪除,首先不談使用者是否想要整體刪除,在這塊有個模糊查詢的功能,如果每刪一個字母之後去調介面查詢資料庫造成一些不必要的效能開銷,哪怕加上防抖。

然後也是找了其他的庫都沒達到產品的期望效果,那麼好,自己實現一個,先看看最終實現的效果

封裝之後使用:

<AtInput
    height={150}
    onRequest={async (searchStr) => {
        const { data } = await UserFindAll({ nickname: searchStr });
        return data?.list?.map((v) => ({
            id: v.uid,
            name: v.nickname,
            wechatAvatarUrl: v.wechatAvatarUrl,
        }));
    }}
    onChange={(content, selected) => {
        setAtUsers(selected);
    }}
/>

那麼實現這麼一個輸入框大概有以下幾個點:

  1. 高亮效果
  2. 刪除/選中使用者時需要整體刪除
  3. 監聽@的位置,複製給彈框的座標,聯動效果
  4. 最後我需要拿到文字內容,並且需要拿到@那些使用者,去做表單提交

大多數文字輸入框我們會使用input,或者textarea,很明顯以上1,2兩點實現不了,antd也是使用的textarea,所以也是沒有實現這兩個效果。所以這塊使用富文字編輯,設定contentEditable,將其變為可編輯去做。輸入框以及選擇器的dom就如下:

 <div style={{ height, position: 'relative' }}>
       {/* 編輯器 */}
       <div 
           id="atInput" 
           ref={atRef} 
           className={'editorDiv'} 
           contentEditable 
           onInput={editorChange} 
           onClick={editorClick} 
       />
       {/* 選擇使用者框 */}
       <SelectUser 
           options={options} 
           visible={visible} 
           cursorPosition={cursorPosition} 
           onSelect={onSelect} 
       />
 </div>

實現思路:

  1. 監聽輸入@,喚起選擇框。
  2. 擷取@xxx的xxx作為搜素的關鍵字去查詢介面
  3. 選擇使用者後需要將原先輸入的 @xxx 替換成 @姓名,並且將@的使用者快取起來
  4. 選擇文字框中的姓名時需要變為整體選中狀態,這塊依然可以給標籤設定為不可編輯狀態就可實現,contentEditable=false,即可實現整體刪除,在刪除的同時需要將當前使用者從之前快取的@過的使用者陣列刪除
  5. 那麼可以拿到輸入框的文字,@的使用者, 最後將資料拋給父元件就完成了

以上提到了監聽@文字變化,通常繫結onChange事件就行,但是還有一種使用者透過點選移動游標,這塊需要繫結change,click兩個時間,他們裡邊的邏輯基本一樣,只需要額外處理點選選中輸入框中使用者時,整體選中g功能,那麼程式碼如下:

    const onObserveInput = () => {
        let cursorBeforeStr = '';
        const selection: any = window.getSelection();
        if (selection?.focusNode?.data) {
            cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
        }
        setFocusNode(selection.focusNode);
        const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
        setCurrentAtIdx(lastAtIndex);
        if (lastAtIndex !== -1) {
            getCursorPosition();
            const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
            if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
                setSearchStr(searchStr);
                fetchOptions(searchStr);
                setVisible(true);
            } else {
                setVisible(false);
                setSearchStr('');
            }
        } else {
            setVisible(false);
        }
    };

    const selectAtSpanTag = (target: Node) => {
        window.getSelection()?.getRangeAt(0).selectNode(target);
    };

    const editorClick = async (event) => {
        onObserveInput();
        // 判斷當前標籤名是否為span 是的話選中當做一個整體
        if (e.target.localName === 'span') {
            selectAtSpanTag(e.target);
        }
    };

    const editorChange = (event) => {
        const { innerText } = event.target;
        setContent(innerText);
        onObserveInput();
    };

每次點選或者文字改變時都會去呼叫onObserveInput,以上onObserveInput該方法中主要做了以下邏輯:

  • 在此之前需要先了解 Selection的一些方法
  1. 透過getSelection方法可以獲取游標的偏移位置,那麼可以擷取游標之前的字串,並且使用lastIndexOf從後向前查詢最後一個“@”符號,並記錄他的下標,那麼有了【游標之前的字串】,【@的下標】就可以拿到到@之後用於過濾使用者的關鍵字,並將其快取起來。
  2. 喚起選擇器,並透過關鍵字去過濾使用者。這塊涉及到一個選擇器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去獲取游標的位置拿到的是游標相對於視窗的座標,直接用這個座標會有問題,比如捲軸滾動時,這個選擇器發生位置錯亂,所以這塊同時去拿輸入框的座標,去做一個相減,這樣就可以實現選擇器跟著@符號聯動的效果。
 const getCursorPosition = () => {
        // 座標相對瀏覽器的座標
        const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
        // 獲取編輯器的座標
        const editorDom = window.document.querySelector('#atInput');
        const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
        // 游標所在位置
        setCursorPosition({ x: x - eX, y: y - eY });
};
選擇器彈出後,那麼下面就到了選擇使用者之後的流程了,
 /**
     * @param id 唯一的id 可以uid
     * @param name 使用者姓名
     * @param color 回顯顏色
     * @returns
     */
    const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
        const ele = document.createElement('span');
        ele.className = 'at-span';
        ele.style.color = color;
        ele.id = id.toString();
        ele.contentEditable = 'false';
        ele.innerText = `@${name}`;
        return ele;
    };

    /**
     * 選擇使用者時回撥
     */
    const onSelect = (item: Options) => {
        const selection = window.getSelection();
        const range = selection?.getRangeAt(0) as Range;
        // 選中輸入的 @關鍵字  -> @鄭
        range.setStart(focusNode as Node, currentAtIdx!);
        range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
        // 刪除輸入的 @關鍵字
        range.deleteContents();
        // 建立元素節點
        const atEle = createAtSpanTag(item.id, item.name);
        // 插入元素節點
        range.insertNode(atEle);
        // 游標移動到末尾
        range.collapse();
        // 快取已選中的使用者
        setSelected([...selected, item]);
        // 選擇使用者後重新計算content
        setContent(document.getElementById('atInput')?.innerText as string);
        // 關閉彈框
        setVisible(false);
        // 輸入框聚焦
        atRef.current.focus();
    };

選擇使用者的時候需要做的以下以下幾點:

  1. 刪除之前的@xxx字元
  2. 插入不可編輯的span標籤
  3. 將當前選擇的使用者快取起來
  4. 重新獲取輸入框的內容
  5. 關閉選擇器
  6. 將輸入框重新聚焦

最後

在選擇的使用者或者內容發生改變時將資料拋給父元件

 const getAttrIds = () => {
        const spans = document.querySelectorAll('.at-span');
        let ids = new Set();
        spans.forEach((span) => ids.add(span.id));
        return selected.filter((s) => ids.has(s.id));
    };

    /**  @的使用者列表發生改變時,將最新值暴露給父元件 */
    useEffect(() => {
        const selectUsers = getAttrIds();
        onChange(content, selectUsers);
    }, [selected, content]);

完整元件程式碼

輸入框主要邏輯程式碼:

let timer: NodeJS.Timeout | null = null;

const AtInput = (props: AtInputProps) => {
    const { height = 300, onRequest, onChange, value, onBlur } = props;
    // 輸入框的內容=innerText
    const [content, setContent] = useState<string>('');
    // 選擇使用者彈框
    const [visible, setVisible] = useState<boolean>(false);
    // 使用者資料
    const [options, setOptions] = useState<Options[]>([]);
    // @的索引
    const [currentAtIdx, setCurrentAtIdx] = useState<number>();
    // 輸入@之前的字串
    const [focusNode, setFocusNode] = useState<Node | string>();
    // @後關鍵字 @鄭 = 鄭
    const [searchStr, setSearchStr] = useState<string>('');
    // 彈框的x,y軸的座標
    const [cursorPosition, setCursorPosition] = useState<Position>({
        x: 0,
        y: 0,
    });
    // 選擇的使用者
    const [selected, setSelected] = useState<Options[]>([]);
    const atRef = useRef<any>();

    /** 獲取選擇器彈框座標 */
    const getCursorPosition = () => {
        // 座標相對瀏覽器的座標
        const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
        // 獲取編輯器的座標
        const editorDom = window.document.querySelector('#atInput');
        const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
        // 游標所在位置
        setCursorPosition({ x: x - eX, y: y - eY });
    };

    /**獲取使用者下拉選單 */
    const fetchOptions = (key?: string) => {
        if (timer) {
            clearTimeout(timer);
            timer = null;
        }
        timer = setTimeout(async () => {
            const _options = await onRequest(key);
            setOptions(_options);
        }, 500);
    };

    useEffect(() => {
        fetchOptions();
        // if (value) {
        //     /** 判斷value中是否有at使用者 */
        //     const atUsers: any = StringTools.filterUsers(value);
        //     setSelected(atUsers);
        //     atRef.current.innerHTML = value;
        //     setContent(value.replace(/<\/?.+?\/?>/g, '')); //全域性匹配內html標籤)
        // }
    }, []);

    const onObserveInput = () => {
        let cursorBeforeStr = '';
        const selection: any = window.getSelection();
        if (selection?.focusNode?.data) {
            cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
        }
        setFocusNode(selection.focusNode);
        const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
        setCurrentAtIdx(lastAtIndex);
        if (lastAtIndex !== -1) {
            getCursorPosition();
            const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
            if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
                setSearchStr(searchStr);
                fetchOptions(searchStr);
                setVisible(true);
            } else {
                setVisible(false);
                setSearchStr('');
            }
        } else {
            setVisible(false);
        }
    };

    const selectAtSpanTag = (target: Node) => {
        window.getSelection()?.getRangeAt(0).selectNode(target);
    };

    const editorClick = async (e?: any) => {
        onObserveInput();
        // 判斷當前標籤名是否為span 是的話選中當做一個整體
        if (e.target.localName === 'span') {
            selectAtSpanTag(e.target);
        }
    };

    const editorChange = (event: any) => {
        const { innerText } = event.target;
        setContent(innerText);
        onObserveInput();
    };

    /**
     * @param id 唯一的id 可以uid
     * @param name 使用者姓名
     * @param color 回顯顏色
     * @returns
     */
    const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
        const ele = document.createElement('span');
        ele.className = 'at-span';
        ele.style.color = color;
        ele.id = id.toString();
        ele.contentEditable = 'false';
        ele.innerText = `@${name}`;
        return ele;
    };

    /**
     * 選擇使用者時回撥
     */
    const onSelect = (item: Options) => {
        const selection = window.getSelection();
        const range = selection?.getRangeAt(0) as Range;
        // 選中輸入的 @關鍵字  -> @鄭
        range.setStart(focusNode as Node, currentAtIdx!);
        range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
        // 刪除輸入的 @關鍵字
        range.deleteContents();
        // 建立元素節點
        const atEle = createAtSpanTag(item.id, item.name);
        // 插入元素節點
        range.insertNode(atEle);
        // 游標移動到末尾
        range.collapse();
        // 快取已選中的使用者
        setSelected([...selected, item]);
        // 選擇使用者後重新計算content
        setContent(document.getElementById('atInput')?.innerText as string);
        // 關閉彈框
        setVisible(false);
        // 輸入框聚焦
        atRef.current.focus();
    };

    const getAttrIds = () => {
        const spans = document.querySelectorAll('.at-span');
        let ids = new Set();
        spans.forEach((span) => ids.add(span.id));
        return selected.filter((s) => ids.has(s.id));
    };

    /**  @的使用者列表發生改變時,將最新值暴露給父元件 */
    useEffect(() => {
        const selectUsers = getAttrIds();
        onChange(content, selectUsers);
    }, [selected, content]);

    return (
        <div style={{ height, position: 'relative' }}>
            {/* 編輯器 */}
            <div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
            {/* 選擇使用者框 */}
            <SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
        </div>
    );
};

選擇器程式碼

const SelectUser = React.memo((props: SelectComProps) => {
  const { options, visible, cursorPosition, onSelect } = props;

  const { x, y } = cursorPosition;

  return (
    <div
      className={'selectWrap'}
      style={{
        display: `${visible ? 'block' : 'none'}`,
        position: 'absolute',
        left: x,
        top: y + 20,
      }}
    >
      <ul>
        {options.map((item) => {
          return (
            <li
              key={item.id}
              onClick={() => {
                onSelect(item);
              }}
            >
              <img src={item.wechatAvatarUrl} alt="" />
              <span>{item.name}</span>
            </li>
          );
        })}
      </ul>
    </div>
  );
});
export default SelectUser;

以上就是實現一個支援@使用者的輸入框功能,就目前而言,比較死板,不支援自定義顏色,自定義選擇器等等,未來,可以進一步擴充套件功能,例如新增@使用者的高亮樣式定製、支援鍵盤快捷鍵操作等,從而提升使用者體驗和功能性。

本文轉載於:https://juejin.cn/post/7357917741909819407

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。

記錄---實現一個支援@的輸入框

相關文章