Article source: github.com/Haixiang612…

Preview link: yanhaixiang.com/my-react-co…

Reference the wheels: www.npmjs.com/package/rea…

I once saw an article on “What can a One-liner agent Do? Answer:

At that time tried really very fun, so every time can show a wave of operation in front of the sister, in their amazed eyes, MY heart happily smiled — well, and let a person who does not understand the technology found the United States 🐶, cough.

For a long time, I felt that this attribute existed just for the sake of existence, but after receiving the request today, I found that this attribute, which felt useless, was the perfect solution to my need.

A demand

The requirements are simple, just add a button to the input field. This function is usually used for mass mail, where the button “name” is actually a variable, and the backend should automatically fill in the name of the real user, and then send the email to the user.

This requirement at first feels like adding a button to a Textarea, but it’s not true: a textarea does not add a button. What about div wrapping? Div can’t be typed. The contentEditable property is one that users can enter manually.

Here is a hand-written example of a react-Contenteditable wheel.

Use cases

For the controlled component of the input element, you must have props for value and onChange, like this:

function App() {
  const [value, setValue] = useState(' ');

  const onChange = (e: ContentEditableEvent) = > {
    console.log('change', e.target.value)
    setValue(e.target.value)
  }

  return (
    <div style={{ border: '1px solid black' }}>
      <ContentEditable style={{ height: 300 }} value={value} onChange={onChange} />
    </div>
  );
}
Copy the code

Consider the contentEditable property again: an enumerated property that indicates whether an element can be edited by a user. The browser modifies the parts of the element to allow editing. See the MDN documentation for details.

To insert HTML, use the dangerouslySetInnerHTML property to set innerHTML and perform the onChange callback via onInput. A simple implementation is as follows:

// Modified onChange event
export type ContentEditableEvent = SyntheticEvent<any, Event> & {
  target: { value: string}};interfaceProps { value? :string / / valueonChange? :(e: ContentEditableEvent) = > void // Callback for value changes
}

class ContentEditable extends Component<Props> {
  lastHtml = this.props.value // Record the last value
  ref = createRef<HTMLDivElement>() // The current container

  emitEvent = (originalEvent: SyntheticEvent<any>) = > {
    if (!this.ref.current) return

    const html = this.ref.current.innerHTML
    if (this.props.onChange && html ! = =this.lastHtml) { // Call only when the value is different from the last one
      const event = { // Merge events, where the target. Value value is changed. originalEvent,target: {
          ...originalEvent.target,
          value: html || ' '}}this.props.onChange(event) // Perform the callback}}render() {
    const { value } = this.props

    return (
      <div
        ref={this.ref}
        contentEditable
        onInput={this.emitEvent}
        dangerouslySetInnerHTML={{__html: value| | '}} / >)}}Copy the code

But you’ll soon find a problem: how come all the words are typed backwards? For example, if you type “hello”, it will read:

Solve the problem of reverse output

If you remove setValue(e.target.value) from onChange, you will find that the bug is gone and the output is normal again.

This is because the component will be re-rendered every time setValue is set, and the cursor will run to the front of the rendering every time, so when setValue is set, there will be the problem of reverse order output.

The solution is to put the cursor back at the end of componentDidUpdate, returning to the last position after each rendering.

const replaceCaret = (el: HTMLElement) = > {
  // Create cursor
  const cursor = document.createTextNode(' ')
  el.appendChild(cursor)

  // Check whether it is selected
  const isFocused = document.activeElement === el
  if(! cursor || ! cursor.nodeValue || ! isFocused)return

  // Place the cursor at the end
  const selection = window.getSelection()
  if(selection ! = =null) {
    const range = document.createRange()
    range.setStart(cursor, cursor.nodeValue.length)
    range.collapse(true)

    selection.removeAllRanges()
    selection.addRange(range)
  }

  / / the focus again
  if (el instanceof HTMLElement) el.focus()
}

class ContentEditable extends Component<Props> {
  lastHtml = this.props.value
  ref = createRef<HTMLDivElement>()

  componentDidUpdate() {
    if (!this.ref.current) return

    this.lastHtml = this.props.value

    replaceCaret(this.ref.current) // Place the cursor at the end}... }Copy the code

Note here: for a Range, it can be either a selection or a cursor. Create a Range, setCollapse(true) set the Range to empty and the selection becomes the cursor’s. The Range is then placed in the created Node, which is then placed at the end of the container. This gives the “cursor to the end” effect.

checkUpdate

Some people might wonder: when you use input components like input, if you don’t setValue in onChange, the value won’t change. It is mentioned above that you can input the value again without adding setValue, which means that you do not need to manually update the value. Can you optimize the input performance?

The answer is yes, with performance optimizations in the react-contentedtiable source code.

  shouldComponentUpdate(nextProps: Props): boolean {
    const { props } = this;
    const el = this.getEl();

    // We need not rerender if the change of props simply reflects the user's edits.
    // Rerendering in this case would make the cursor/caret jump

    // Rerender if there is no element yet... (somehow?)
    if(! el)return true;

    / /... or if html really changed... (programmatically, not by user edit)
    if( normalizeHtml(nextProps.html) ! == normalizeHtml(el.innerHTML) ) {return true;
    }

    // Handle additional properties
    returnprops.disabled ! == nextProps.disabled || props.tagName ! == nextProps.tagName || props.className ! == nextProps.className || props.innerRef ! == nextProps.innerRef || ! deepEqual(props.style, nextProps.style); }Copy the code

But then there are bugs caused by blocking updates: github.com/lovasoa/rea…

In this Issue, it says that because onBlur is not updated, the value will not change every time the blur event is triggered after the value is changed. Is it possible to add onBlur checks? ShouldComponentUpdate should return true if onInput, onClick, etc. It has no effect on performance optimization.

A compromise would be to add a checkUpdate props for the user to perform the optimization. The source code is used to determine whether each value and some props update needs to be updated.

interface Props extendsHTMLAttributes<HTMLElement> { value? :stringonChange? :(e: ContentEditableEvent) = > voidcheckUpdate? :(nextProps: Props, thisProps: Props) = > boolean // Determine whether an update should be made
}
Copy the code

Return this function in shouldComponentUpdate:

class ContentEditable extends Component<Props> {... shouldComponentUpdate(nextProps: Readonly<Props>):boolean {
    if (this.props.checkUpdate) {
      return this.props.checkUpdate(nextProps, this.props)
    }
    return true}... }Copy the code

innerRef

The code above to get container elements by ref is redundant and does not expose ref outwardly. This step optimizes getting the container element code and exposing the REF parameter.

interface Props extendsHTMLAttributes<HTMLElement> { disabled? :booleanvalue? :stringonChange? :(e: ContentEditableEvent) = > voidinnerRef? : React.RefObject<HTMLDivElement> |Function // Externally exposed refcheckUpdate? :(nextProps: Props, thisProps: Props) = > boolean
}
Copy the code

Note that ref can be a REF object or a function, and be compatible with both.

class ContentEditable extends Component<Props> {
  private lastHtml: string = this.props.value || ' '
  private el: HTMLElement | null = null

  componentDidUpdate() {
    const el = this.getEl()

    if(! el)return

    this.lastHtml = this.props.value || ' '

    replaceCaret(el)
  }

  getEl = (): HTMLElement | null= > { // Get the container method
    const {innerRef} = this.props

    if(!!!!! innerRef &&typeofinnerRef ! = ='function') {
      return innerRef.current
    }

    return this.el
  }

  emitEvent = (originalEvent: SyntheticEvent<any>) = > {
    const el = this.getEl()

    if(! el)return

    const html = el.innerHTML
    if (this.props.onChange && html ! = =this.lastHtml) {
      constevent = { ... originalEvent,target: {
          value: html || ' '}}// @ts-ignore
      this.props.onChange(event)
    }

    this.lastHtml = html
  }

  render() {
    const{ disabled, value, innerRef, ... passProps } =this.props

    return (
      <div
        {. passProps}
        ref={typeof innerRef= = ='function' ? (node: HTMLDivElement) = > {
          innerRef(node)
          this.el = node
        }: innerRef || null}
        contentEditable
        onInput={this.emitEvent}
        onBlur={this.props.onBlur || this.emitEvent}
        onKeyUp={this.props.onKeyUp || this.emitEvent}
        onKeyDown={this.props.onKeyDown || this.emitEvent}
        dangerouslySetInnerHTML={{__html: value || ''}}
      >
        {this.props.children}
      </div>)}}Copy the code

The getEl function was added to get the current container.

Supplement the props

In addition to the above important props, there are also props to enhance extensibility, such as Disabled and tagName.

class ContentEditable extends Component<Props> {...render() {
    const{tagName, value, innerRef, ... passProps} =this.props

    return createElement(
      tagName || 'div',
      {
        ...passProps,
        ref: typeof innerRef === 'function' ? (node: HTMLDivElement) = > {
          innerRef(node)
          this.el = node
        } : innerRef || null.contentEditable:!this.props.disabled,
        onInput: this.emitEvent,
        onBlur: this.props.onBlur || this.emitEvent,
        onKeyUp: this.props.onKeyUp || this.emitEvent,
        onKeyDown: this.props.onKeyDown || this.emitEvent,
        dangerouslySetInnerHTML: {__html: value || ' '}},this.props.children
    )
  }
}
Copy the code

conclusion

At this point, a react-Contenteditable component is complete, which mainly implements:

  • Value and onChange data flows
  • incomponentDidUpdateTo deal with the problem that the cursor is always placed first
  • inshouldComponentUpdateaddcheckUpdate, developers to optimize rendering performance
  • Expose the props of ref, Disabled, tagName externally

Although react-Contenteditable looks good, a look at the source code shows that there are many compatibility issues with the library that are not taken into account, such as this Stackoverflow discussion, plus the egg pain Issue mentioned above, If you really want to implement rich text in a production environment it is best not to use this library and draft.js is recommended. Of course, simple functions implemented in this library are relatively lightweight.