在Draft JS中实现无触发符标签自动补全及弹窗位置优化
Let's break down your popup positioning issues first, then move on to better autocomplete patterns for your tag editor.
Fixing Popup Positioning Problems
1. Sync with Draft.js's Native Selection Tools
Your current code uses the browser's native window.getSelection(), which can get out of sync with Draft.js's internal state—especially on the first input, when the DOM hasn't fully updated to reflect the new text. Instead, use Draft.js's built-in getVisibleSelectionRect utility, which is designed to accurately capture the editor's selection position:
import getVisibleSelectionRect from 'draft-js/lib/getVisibleSelectionRect'; // Replace your native selection logic in onChange with this: const visibleRect = getVisibleSelectionRect(window); if (this.state.autoComplete.search !== categorySearch && visibleRect) { this.setState((prevState: StateType) => { const state = { autoComplete: { active: true, search: categorySearch, searchMeta: { begin: wordMeta.begin, end: wordMeta.end, }, }, selectionRect: prevState.selectionRect, }; if (prevState.autoComplete.active === false) { // Use Draft.js's visible rect directly—it includes top/left/width/height state.selectionRect = visibleRect; } return state; }); }
2. Wait for DOM Updates Before Measuring
On first input, Draft.js might not have finished rendering the new text to the DOM yet, leading to an incorrect (or missing) selection rect. Add a small delay using requestAnimationFrame to ensure the DOM is up-to-date:
if (prevState.autoComplete.active === false) { requestAnimationFrame(() => { const updatedRect = getVisibleSelectionRect(window); if (updatedRect) { this.setState({ selectionRect: updatedRect }); } }); }
3. Fix getWordAt Edge Cases
Your current getWordAt function can miscalculate word boundaries after spaces, which causes the popup to shift right. Adjust the regex logic to more reliably find word edges:
function getWordAt(text: string, pos: number): WordMeta { // Find the start of the current word (left to nearest space/start of text) const leftMatch = text.slice(0, pos).match(/\S*$/); const left = leftMatch ? leftMatch.index : 0; // Find the end of the current word (right to nearest space/end of text) const rightMatch = text.slice(pos).match(/\s|$/); const right = rightMatch ? pos + rightMatch.index : text.length; return { word: text.slice(left, right), begin: left, end: right, }; }
Better Autocomplete Patterns for Tag Editors
1. Use React Portal for Popup Placement
Mount your autocomplete popup to the <body> using React Portal. This avoids positioning conflicts with parent elements that have overflow: hidden or custom positioning, and ensures the popup stays visible even if the editor scrolls:
import ReactDOM from 'react-dom'; // In your component's render method: {this.state.autoComplete.active && this.state.selectionRect && ReactDOM.createPortal( <div className="autocomplete-popup" style={{ position: 'absolute', top: `${this.state.selectionRect.bottom}px`, left: `${this.state.selectionRect.left}px`, zIndex: 1000, // Add your styling here }} > {/* Your autocomplete options go here */} </div>, document.body )}
2. Debounce Search Updates
Prevent excessive state updates and potential API calls by adding a debounce to your autocomplete logic. This waits until the user stops typing for a short period before updating the popup:
import debounce from 'lodash/debounce'; // In your component constructor: this.debouncedUpdateAutocomplete = debounce((categorySearch, rect, wordMeta) => { this.setState({ autoComplete: { active: true, search: categorySearch, searchMeta: { begin: wordMeta.begin, end: wordMeta.end }, }, selectionRect: rect, }); }, 300); // In your onChange function, replace the setState call with: if (!existingEntity && matches) { const categorySearch = matches[1]; const visibleRect = getVisibleSelectionRect(window); if (this.state.autoComplete.search !== categorySearch && visibleRect) { this.debouncedUpdateAutocomplete(categorySearch, visibleRect, wordMeta); } }
3. Centralize Logic with Custom Hooks (If Using Functional Components)
If you're using functional components, extract the autocomplete logic into a custom Hook to keep your code clean and reusable:
import { useState, useEffect } from 'react'; import getVisibleSelectionRect from 'draft-js/lib/getVisibleSelectionRect'; function useTagAutocomplete(editorState) { const [autoComplete, setAutoComplete] = useState({ active: false, search: '', searchMeta: null }); const [selectionRect, setSelectionRect] = useState(null); useEffect(() => { const content = editorState.getCurrentContent(); const selection = editorState.getSelection(); if (selection.isCollapsed()) { const currentBlock = content.getBlockForKey(selection.getAnchorKey()); const blockText = currentBlock.getText(); const wordMeta = getWordAt(blockText, selection.getAnchorOffset()); const matches = wordMeta.word.match(/([\w]*)/); const existingEntity = currentBlock.getEntityAt(wordMeta.begin); if (!existingEntity && matches) { const categorySearch = matches[1]; const visibleRect = getVisibleSelectionRect(window); setAutoComplete({ active: true, search: categorySearch, searchMeta: wordMeta }); setSelectionRect(visibleRect); } else { setAutoComplete(prev => ({ ...prev, active: false })); } } else { setAutoComplete(prev => ({ ...prev, active: false })); } }, [editorState]); return { autoComplete, selectionRect }; }
4. Properly Manage Draft.js Entities
When a user selects an autocomplete option, create a Draft.js Entity to mark the tag as a completed entity. This prevents the autocomplete from triggering again on the same text:
function insertTag(editorState, tagName) { const content = editorState.getCurrentContent(); const selection = editorState.getSelection(); const currentBlock = content.getBlockForKey(selection.getAnchorKey()); const wordMeta = getWordAt(currentBlock.getText(), selection.getAnchorOffset()); // Create a tag entity const entityKey = content.createEntity('TAG', 'IMMUTABLE', { name: tagName }); // Replace the current word with the selected tag const newContent = content.replaceText( selection.merge({ anchorOffset: wordMeta.begin, focusOffset: wordMeta.end }), tagName, null, entityKey ); return EditorState.push(editorState, newContent, 'replace'); }
内容的提问来源于stack exchange,提问作者Victor

