I’m participating in nuggets Creators Camp # 4, click here to learn more and learn together!

I made a seemingly simple input association function before, but actually there are many pits, similar to the @selection function, today I will share the pit record with you.

Requirements describe

  • Text input box;

  • Enter the @ symbol / @ + keyword to associate the result list, which is displayed in the floating window at the cursor.

  • Click the list of associative results, replace the typed @ + associative keyword with selected associative results, and display the blue font;

  • After the association is triggered, if the association result is not selected, it is only used as plain text. If the association result is not selected, the association is ended by space.

  • Before submission, you can edit repeatedly, so you need to save the edited content and backfill display, the database stores HTML strings, convenient to repeatedly edit backfill display;

  • Save the edited content when you lose focus after editing; After completion and submission, it can not be edited, but can only be viewed. When viewing, more information will be displayed in the floating window when the mouse is hovering over the highlighted text.

  • Display Placeholder;

  • Length: a maximum of 200 characters (statistics of entered characters/total characters are displayed at the lower right corner of the text box).

  • All types of formats are acceptable, including numbers, Chinese and English, special characters, newlines, Spaces, etc.

Functional analysis

The function of limiting the maximum number of words in the rich text editor was not found in the research process, so it was abandoned.

So the contentEditable Div, with its customizable width and height and CSS style for internal nodes, seems to be mostly satisfying;

Other Placeholder features, real-time length, maximum length limits, and input listening all look like normal features.

The function development

The React + AntD project was developed.

structure

<div className={'editDivContentWrap'} ><div
        contentEditable={true}
        ref={editableDivRef}
        placeholder={placeholder}
        dangerouslySetInnerHTML={{ __html: htmlStr }}
        onKeyUp = {handleEditableKeyUp}
        onKeyPress = {handleEditableKeyPress}
        onKeyDown = {handleEditableKeyDown}
        onBlur = {handleEditBlur}
        onMouseUp={handleEditableMouseUp}
        onPaste={handleEditablePaste}
        onCompositionStart={handleCompositionStart}
        onCompositionEnd={handleCompositionEnd}
    />
    <div className={'textLength'} >
        <span>{hasInputLength}</span>/
        <span>{MAXLENGTH}</span>
    </div>
    {
        showSearchResult && 
        <div className={'searchResultTableWrap'} >
            XXX
        </div>
    }
</div>
Copy the code

Record on pit

  1. Placeholder

There were problems implementing the seemingly simplest Placeholder, crying…

  • contentEditable diventer

Type



and type English 1 instead of

1

;

When there’s nothing in the input box, the first time you type enter, you type





, because if you don’t type two newlines it shows up in the view as no newlines, and that’s what the browser does;

Try to use \n to implement newline, results in the following problem, so still choose the browser processing;

  • EnterTo prevent default typing, replace with\n

If there is no content in the input box, the page displays no line break when you type Enter for the first time. In The Chinese input method, two first letters are entered when you type Enter for the first time.

  • myPlaceholder

Eventually Placeholder implementation;

const handlePlaceholder = () = > {
    // Place a placeholder in the edit box and press Enter, then delete
    const htm = editableDivRef.current.innerHTML;
    if(htm.length ! = =0&& htm ! = ='<div><br></div>') {
        setMyPlaceholder(' ');
    } else {
        setMyPlaceholder('myPlaceholder');
        if (htm === '<div><br></div>') {
            editableDivRef.current.innerHTML = ' '; }}}Copy the code
  1. Lenovo floating window

Then there are the lenovo floating Windows, still with problems, crying…

There is no key control selection associative results, put in the next phase, really liver can not come over, thanks to the product greatly do not kill the grace;

  • Click on the Associative result to replace it with highlighted text

Select @+ associative keyword and replace it with highlighted text.

Highlight text using Button+Css, highlight text as a whole processing, can only be deleted as a whole, not select and edit;

const itemClick = (record) = > {
  // Hide the floating window
  updateSearchFlag(false.null);
  // Get the input to determine the maximum length
  const txt = editableDivRef.current.textContent;
  const length = txt.length;
  // Select the text you want to use in the associative result to highlight it
  const code = record.codeNum || ' ';
  const addCodeLen = code.toString().length;
  if (length > MAXLENGTH || (addCodeLen + length > MAXLENGTH)) {
    // Input cannot continue beyond the maximum length
    maxLengthTipFun();
    return false;
  } else {
    // Update the real-time length
    setHasInputLength(addCodeLen + 1 + length);
  }
  if (editableDivRef.current) {
    editableDivRef.current.focus();
    // delete the associative draft start
    const editorRangeRange = editorRange.range;
    if(! editorRangeRange)return;
    // Get the last text node
    const textNode = editorRangeRange.endContainer;
    // Cursor position
    const endOffset = editorRangeRange.endOffset;
    // Locate the at symbol before the cursor
    const textNodeValue = textNode.nodeValue;
    const expRes = (/ @ $/ ([^ @] *)).exec(textNodeValue);
    if (expRes && expRes.length > 1) {
      editorRangeRange.setStart(textNode, expRes.index);
      editorRangeRange.setEnd(textNode, endOffset);
      editorRangeRange.deleteContents(); // Delete the associative draft end
      // Use Button to highlight text
      const btn = document.createElement('button');
      btn.className = `createCodeNum`;
      btn.textContent = ` @${code}`;
      // Highlight text is not editable
      btn.contentEditable = 'false';
      // Prevents the cursor from switching to the inside of the highlighted text
      btn.addEventListener('click'.() = > {
        return false
      }, false);
      // Not selectable
      btn.tabIndex = -1;
      // Space characters for cursor placement
      const bSpaceNode = document.createTextNode(' ');
      insertHtmlAtCaret([btn, bSpaceNode], editorRange.selection, editorRange.range)
    }
  }
}
Copy the code

Cursor position updated after replacement;

This paragraph actually I also do not quite understand, have big guy to see also please don’t hesitate to give advice, thank!

const insertHtmlAtCaret = ([btn, bSpaceNode], selection, range) = > {
  if (selection.getRangeAt && selection.rangeCount) {
    if (selection.focusNode.parentNode.nodeName === 'BUTTON') return;
    range.deleteContents();
    const el = document.createElement("div");
    el.appendChild(btn);
    el.appendChild(bSpaceNode);
    let frag = document.createDocumentFragment(), node, lastNode;
    while ((node = el.firstChild)) {
      lastNode = frag.appendChild(node);
    }
    range.insertNode(frag);
    if (lastNode) {
      range = range.cloneRange();
      range.setStartAfter(lastNode);
      range.collapse(true); selection.removeAllRanges(); selection.addRange(range); }}}Copy the code
  • Floating window click on the

There is a problem caused by the trigger timing of click and blur. When the floating window is displayed, click the floating window list item, the trigger sequence is to enter the box blur first and then trigger the click of the floating window list item, so the floating window list item cannot be clicked.

When the floating window is displayed, if you directly click other areas to lose focus, you will not save, instead, you should trigger save. You should be able to monitor click processing in document. After friendly communication with the product, you will click the page back button to save, leaving this pit.

const handleEditBlur = (e) = > {
  // Blur is triggered first and itemClick is triggered later
  setTimeout(() = > {
    updateSearchFlag(false.null);
  }, 300)
  if (showSearchResult) {
    // When the float window is displayed, Blur is triggered first and itemClick is triggered again. When the focus is lost, the interface save is not requested, because the focus is also lost when the float window is selected
    // If you click on another area after the float window is displayed and lose focus, you will not save
  } else {
    // Get edit content, request interface save
    if (editableDivRef.current.textContent.length > MAXLENGTH) {
      handleBlur(sliceOverText());
    } else{ handleBlur(editableDivRef.current.innerHTML); }}}Copy the code
  • Floating window position calculation

Introducing the caret – pos;

const getResultPosition = () = > {
  // Associated with the scroll displacement
  // Relative to the outer element
  // const pos = position(input); // { left: 15, top: 30, height: 20, pos: 15 }
  // Relative view
  // const off = offset(input); // { left: 15, top: 30, height: 20 }
  const pos = position(editableDivRef.current);
  const off = offset(editableDivRef.current);
  / /
  const clientWidth = editableDivRef.current.clientWidth;
  let toClientLeft = pos.left;
  if (toClientLeft + TABLE_WIDTH >= clientWidth) {
    toClientLeft = clientWidth - TABLE_WIDTH;
  }
  / / up and down
  const fixHeightGap = TITLE_HEIGHT + 2 + 15;
  const clientHeight = document.scrollingElement.clientHeight;
  const scrollTop = document.scrollingElement.scrollTop;
  let toClientTop = pos.top;
  if (off.top + TABLE_HEIGHT >= clientHeight + scrollTop) {
    toClientTop = (TABLE_HEIGHT + off.height - fixHeightGap) * -1;
  } else {
    toClientTop = pos.top + fixHeightGap - scrollTop;
  }
  return {
    left: toClientLeft,
    top: toClientTop,
  };
}
Copy the code
  1. Character length calculation

It is calculated when the user enters;

const validateKeyEvent = (e, callback) = > {
  if(notHandleKey.indexOf(e.key) ! = = -1 || compositionFlag) {
    // These keys are not processed, such as Shift
    return;
  }
  const txt = editableDivRef.current.textContent;
  const length = txt.length;
  if (length >= MAXLENGTH && (canUseKey.indexOf(e.key) === -1)) {
    // Cannot be blocked under Chinese input method
    // Losing focus will result in undeletion
    // editableDivRef.current.blur();
    // Input cannot continue beyond the maximum length. Some keys do not block default events
    // setIsOverMaxLength(true);
    const evt = e || window.event;
    evt.preventDefault();
    return false;
  } else {
    // setIsOverMaxLength(false);
    setHasInputLength(length);
    if(canSearch) { callback(e); }}}Copy the code

Four events were monitored;

const handleEditableKeyUp = (e) = > {
  validateKeyEvent(e, (e) = > {
    //}}Copy the code
const handleEditableKeyPress = (e) = > {
  validateKeyEvent(e, (e) = > {
    //}}Copy the code
const handleEditableKeyDown = (e) = > {
  validateKeyEvent(e, (e) = > {
    //}}Copy the code
const handleEditableMouseUp = (e) = > {
  validateKeyEvent(e, (e) = > {
    //}}Copy the code

The historical input is calculated when the output is displayed.

// There are historical records when backfill after updating the length
useEffect(() = > {
  if(htmlStr ! = =null) {
    const tempDom = document.createElement('div');
    tempDom.contentEditable = "true";
    tempDom.innerHTML = htmlStr;
    let len = tempDom.textContent.length;
    setHasInputLength(len);
  } else {
    setHasInputLength(0);
  }
}, [htmlStr]);
Copy the code

When I copy and paste, I calculate the length

const handleEditablePaste = (e) = > {
  // Get the paste board content
  // @ts-ignore
  let pastedText = (e.clipboardData || window.clipboardData).getData('text');
  const rangeInfo = getEditorRange();
  setEditorRange(rangeInfo);
  if (rangeInfo && pastedText) {
    const selectStringLen = rangeInfo.range.toString().length;
    if (pastedText.length + hasInputLength - selectStringLen > MAXLENGTH) {
      maxLengthTipFun();
    } else {
      // Delete the selected content
      rangeInfo.range.deleteContents();
      // Insert the paste content
      const newTextNode = document.createTextNode(pastedText);
      const bSpaceNode = document.createTextNode(' '); insertHtmlAtCaret([newTextNode, bSpaceNode], rangeInfo.selection, rangeInfo.range); }}// Hide the floating window
  updateSearchFlag(false.null);
  e.preventDefault();
  return false;
}
Copy the code
  • Chinese input method length calculation

Due to the particularity of Chinese input method, it is necessary to judge whether Chinese input is over.

const handleCompositionEnd = (e) = > {
  // Control the timing of search when Chinese input is entered
  setCompositionFlag(false);
  // Input in Chinese
  let inputText = e.data;
  const rangeInfo = getEditorRange();
  setEditorRange(rangeInfo);
  if (rangeInfo && inputText) {
    const selectStringLen = rangeInfo.range.toString().length;
    const afterEnterLen = inputText.length + hasInputLength - selectStringLen;
    if (afterEnterLen > MAXLENGTH) {
      const newHtmlStr = sliceOverText();
      setTimeout(() = > {
        editableDivRef.current.innerHTML = newHtmlStr;
        // Cursor positioned to the end
        const childNodesArr = [...editableDivRef.current.childNodes]
        rangeInfo.range.setStartAfter(childNodesArr[childNodesArr.length - 1]);
      });
      e.preventDefault();
      return false;
    } else{ setHasInputLength(afterEnterLen); }}// Hide the floating window
  updateSearchFlag(false.null);
}
Copy the code
const handleCompositionStart = (e) = > {
    // Control the timing of search when Chinese input is entered
    setCompositionFlag(true);
}
Copy the code
  1. Super long, block button after default event

When it is too long, blocking the default event of the key prevents further input. As a result, some other keys are blocked as well, such as up, down, left and right keys that move the cursor, copy and paste, etc.

So in the verification length, the part of the key to do processing;

// Enter the key k, e and y values that are still available when the maximum character is reached
const canUseKey = ['Backspace'.'ArrowLeft'.'ArrowRight'.'ArrowUp'.'ArrowDown'.'PageUp'.'PageDown'.'End'.'Home'];
// Do not handle key k, e, y values
const notHandleKey = ['Control'.'Shift'.'Alt'.'Unidentified'.'Escape'.'Tab'];
Copy the code
  1. Chinese input method

The timing problem of calculating character length and triggering search;

const handleCompositionStart = (e) = > {
    // Control the timing of search when Chinese input is entered
    setCompositionFlag(true);
}
Copy the code
const handleCompositionEnd = (e) = > {
    // Control the timing of search when Chinese input is entered
    setCompositionFlag(false); . }Copy the code
const handleSearchKey = (searchStr, rangeInfo) = > {
    if(! compositionFlag) {// Search after Chinese input is completegetSearch && getSearch(key_words); }}Copy the code
  1. Copy and paste

Copy and paste when replacing elements, cursor position processing;

See the previous length calculation when copying and pasting;

  1. View preview of input completed

View the preview HTML string, get highlighted text when the mouse hover, get more information through the interface, used for floating window display;

Solution: one is converted to node by child element processing, another is processed by string;

I chose string handling because it’s easy;

const handleHtmlStr = (htmlStr, contentType) = > {
  if (htmlStr === null) {
    return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} style={{ margin: '5px 0px 0' }} />;
  }
  // Process strings
  const codeNumStart = htmlStr.split('<button class="createCodeNum" contenteditable="false" tabindex="-1">');
  const result = codeNumStart.map((element, index) = > {
    if (element) {
      if (element.indexOf('</button>') > -1) {
        const codeNumArr = element.split('</button>');
        const codeNumArr2 = codeNumArr[0].split(The '@');
        const codeNum = codeNumArr2[1];
        let codeInfo = ' ';
        new Promise(async() = > {await api.queryCodeInfo(codeNum).then((res) = > {
            if (res && res.status === 200 && res.data) {
              codeInfo = `... `;
            }
          })
            .catch((err) = > {
              console.error(err);
            })
            .finally(() = >{})});return (
          <Fragment>
            <Tooltip
              overlayStyle={{
                maxWidth: '380px',
                fontSize: '12px'}}title={()= > { return codeInfo }}
            >
              <button className="createCodeNum">{`@${codeNum}`}</button>
            </Tooltip>
            <span dangerouslySetInnerHTML={{ __html: codeNumArr[1] }}></span>
          </Fragment>
        );
      } else {
        return <span dangerouslySetInnerHTML={{ __html: element}} ></span>; }}else {
      return ' '; }});return result;
}
Copy the code

conclusion

That’s all there is to it, if you encounter a similar need, please try to be friendly and negotiate a new solution with the product, such as AntD mentions is also good, if communication fails, assess the difficulty and complexity of the task carefully, otherwise…

Of course, if the big guy saw here, but also hope to give advice, the front road is long, let’s look together.