Frage

How do I listen to change events for a contentEditable-based control?

var Number = React.createClass({
    render: function() {
        return <div>
            <span contentEditable={true} onChange={this.onChange}>
                {this.state.value}
            </span>
            =
            {this.state.value}
        </div>;
    },
    onChange: function(v) {
        // Doesn't fire :(
        console.log('changed', v);
    },
    getInitialState: function() {
        return {value: '123'}
    }
});

React.renderComponent(<Number />, document.body);

Code on JSFiddle.

War es hilfreich?

Lösung 2

See Sebastien Lorber's answer which fixes a bug in my implementation.


Use the onInput event, and optionally onBlur as a fallback. You might want to save the previous contents to prevent sending extra events.

I'd personally have this as my render function.

var handleChange = function(event){
    this.setState({html: event.target.value});
}.bind(this);

return (<ContentEditable html={this.state.html} onChange={handleChange} />);

jsbin

Which uses this simple wrapper around contentEditable.

var ContentEditable = React.createClass({
    render: function(){
        return <div
            onInput={this.emitChange}
            onBlur={this.emitChange}
            contentEditable
            dangerouslySetInnerHTML={{__html: this.props.html}}></div>;
    },
    shouldComponentUpdate: function(nextProps){
        return nextProps.html !== this.getDOMNode().innerHTML;
    },
    emitChange: function(){
        var html = this.getDOMNode().innerHTML;
        if (this.props.onChange && html !== this.lastHtml) {

            this.props.onChange({
                target: {
                    value: html
                }
            });
        }
        this.lastHtml = html;
    }
});

Andere Tipps

This is the simplest solution that worked for me.

<div
  contentEditable='true'
  onInput={e => console.log('Text inside div', e.currentTarget.textContent)}
>
Text inside div
</div>

Someone has made a project on NPM with my solution: react-contenteditable

I've encountered another problem that occurs when the browser tries to "reformat" the HTML you just gave it, leading to component always rerendering. See this.

Here's my production contentEditable implementation. It has some additional options over react-contenteditable that you might want, including:

  • locking
  • imperative API allowing to embed HTML fragments
  • ability to reformat the content

Summary:

FakeRainBrigand's solution has worked quite fine for me for some time until I got new problems. ContentEditables are a pain, and are not really easy to deal with React...

This JSFiddle demonstrates the problem.

As you can see, when you type some characters and click on Clear, the content is not cleared. This is because we try to reset the contenteditable to the last known virtual DOM value.

So it seems that:

  • You need shouldComponentUpdate to prevent caret position jumps
  • You can't rely on React's VDOM diffing algorithm if you use shouldComponentUpdate this way.

So you need an extra line, so that whenever shouldComponentUpdate returns 'yes', you are sure the DOM content is actually updated.

So the version here adds a componentDidUpdate and becomes:

var ContentEditable = React.createClass({
    render: function(){
        return <div id="contenteditable"
            onInput={this.emitChange}
            onBlur={this.emitChange}
            contentEditable
            dangerouslySetInnerHTML={{__html: this.props.html}}></div>;
    },

    shouldComponentUpdate: function(nextProps){
        return nextProps.html !== this.getDOMNode().innerHTML;
    },

    componentDidUpdate: function() {
        if (this.props.html !== this.getDOMNode().innerHTML) {
           this.getDOMNode().innerHTML = this.props.html;
        }
    },

    emitChange: function() {
        var html = this.getDOMNode().innerHTML;
        if (this.props.onChange && html !== this.lastHtml) {
            this.props.onChange({
                target: {
                    value: html
                }
            });
        }
        this.lastHtml = html;
    }
});

The virtual DOM stays outdated, and it may not be the most efficient code, but at least it does work :) My bug is resolved


Details:

  1. If you put shouldComponentUpdate to avoid caret jumps, then the contenteditable never rerenders (at least on keystrokes)

  2. If the component never rerenders on key stroke, then React keeps an outdated virtual DOM for this contenteditable.

  3. If React keeps an outdated version of the contenteditable in its virtual DOM tree, then if you try to reset the contenteditable to the value outdated in the virtual DOM, then during the virtual DOM diff, React will compute that there are no changes to apply to the DOM!

This happens mostly when:

  • you have an empty contenteditable initially (shouldComponentUpdate=true,prop="",previous vdom=N/A),
  • the user types some text and you prevent renderings (shouldComponentUpdate=false,prop=text,previous vdom="")
  • after user clicks a validation button, you want to empty that field (shouldComponentUpdate=false,prop="",previous vdom="")
  • as both the newly produced and old virtual DOM are "", React does not touch the DOM.

Since, when the edit is complete the focus from the element is always lost, you could simply use an onBlur event handler.

<div
  onBlur={e => {
    console.log(e.currentTarget.textContent);
  }}
  contentEditable
  suppressContentEditableWarning={true}
>
  <p>Lorem ipsum dolor.</p>
</div>

This probably isn't exactly the answer you're looking for, but having struggled with this myself and having issues with suggested answers, I decided to make it uncontrolled instead.

When editable prop is false, I use text prop as is, but when it is true, I switch to editing mode in which text has no effect (but at least browser doesn't freak out). During this time onChange are fired by the control. Finally, when I change editable back to false, it fills HTML with whatever was passed in text:

/** @jsx React.DOM */
'use strict';

var React = require('react'),
    escapeTextForBrowser = require('react/lib/escapeTextForBrowser'),
    { PropTypes } = React;

var UncontrolledContentEditable = React.createClass({
  propTypes: {
    component: PropTypes.func,
    onChange: PropTypes.func.isRequired,
    text: PropTypes.string,
    placeholder: PropTypes.string,
    editable: PropTypes.bool
  },

  getDefaultProps() {
    return {
      component: React.DOM.div,
      editable: false
    };
  },

  getInitialState() {
    return {
      initialText: this.props.text
    };
  },

  componentWillReceiveProps(nextProps) {
    if (nextProps.editable && !this.props.editable) {
      this.setState({
        initialText: nextProps.text
      });
    }
  },

  componentWillUpdate(nextProps) {
    if (!nextProps.editable && this.props.editable) {
      this.getDOMNode().innerHTML = escapeTextForBrowser(this.state.initialText);
    }
  },

  render() {
    var html = escapeTextForBrowser(this.props.editable ?
      this.state.initialText :
      this.props.text
    );

    return (
      <this.props.component onInput={this.handleChange}
                            onBlur={this.handleChange}
                            contentEditable={this.props.editable}
                            dangerouslySetInnerHTML={{__html: html}} />
    );
  },

  handleChange(e) {
    if (!e.target.textContent.trim().length) {
      e.target.innerHTML = '';
    }

    this.props.onChange(e);
  }
});

module.exports = UncontrolledContentEditable;

I suggest using a MutationObserver to do this. It gives you a lot more control over what is going on. It also gives you more details on how the browse interprets all the keystrokes.

Here in TypeScript:

import * as React from 'react';

export default class Editor extends React.Component {
    private _root: HTMLDivElement; // Ref to the editable div
    private _mutationObserver: MutationObserver; // Modifications observer
    private _innerTextBuffer: string; // Stores the last printed value

    public componentDidMount() {
        this._root.contentEditable = "true";
        this._mutationObserver = new MutationObserver(this.onContentChange);
        this._mutationObserver.observe(this._root, {
            childList: true, // To check for new lines
            subtree: true, // To check for nested elements
            characterData: true // To check for text modifications
        });
    }

    public render() {
        return (
            <div ref={this.onRootRef}>
                Modify the text here ...
            </div>
        );
    }

    private onContentChange: MutationCallback = (mutations: MutationRecord[]) => {
        mutations.forEach(() => {
            // Get the text from the editable div
            // (Use innerHTML to get the HTML)
            const {innerText} = this._root;

            // Content changed will be triggered several times for one key stroke
            if (!this._innerTextBuffer || this._innerTextBuffer !== innerText) {
                console.log(innerText); // Call this.setState or this.props.onChange here
                this._innerTextBuffer = innerText;
            }
        });
    }

    private onRootRef = (elt: HTMLDivElement) => {
        this._root = elt;
    }
}

Here is a component that incorporates much of this by lovasoa: https://github.com/lovasoa/react-contenteditable/blob/master/index.js

He shims the event in the emitChange

emitChange: function(evt){
    var html = this.getDOMNode().innerHTML;
    if (this.props.onChange && html !== this.lastHtml) {
        evt.target = { value: html };
        this.props.onChange(evt);
    }
    this.lastHtml = html;
}

I'm using a similar approach successfully

<div
    spellCheck="false"
    onInput={e => console.log("e: ", e.currentTarget.textContent}
    contentEditable="true"
    suppressContentEditableWarning={true}
    placeholder="Title"
    className="new-post-title"
/>

Here's my hooks-based version based on Sebastien Lorber's answer:

const noop = () => {};
const ContentEditable = ({
  html,
  onChange = noop,
}: {
  html: string;
  onChange?: (s: string) => any;
}) => {
  const ref = useRef<HTMLDivElement>(null);
  const lastHtml = useRef<string>('');

  const emitChange = () => {
    const curHtml = ref.current?.innerHTML || '';
    if (curHtml !== lastHtml.current) {
      onChange(curHtml);
    }
    lastHtml.current = html;
  };

  useEffect(() => {
    if (!ref.current) return;
    if (ref.current.innerHTML === html) return;
    ref.current.innerHTML = html;
  }, [html]);

  return (
    <div
      onInput={emitChange}
      contentEditable
      dangerouslySetInnerHTML={{ __html: html }}
      ref={ref}
    ></div>
  );
};

I've tried to use the example from Saint Laurent:

<div
  onBlur={e => {
    console.log(e.currentTarget.textContent);
  }}
  contentEditable
  suppressContentEditableWarning={true}
>
    <p>Lorem ipsum dolor.</p>
</div>

It's working perfectly until I've tried to set this value in state. When I'm using a functional component that calls setState(e.currentTarget.textContent), I'm getting currentTarget as null. setState works asynchronously and currentTarget is not available there.

The fix that worked for me in React 17.0.2 was to use e.target.innerText:

<div
  onBlur={e => setState(e.target.innerText)}
  contentEditable
  suppressContentEditableWarning={true}
>
    <p>Lorem ipsum dolor.</p>
</div>
Lizenziert unter: CC-BY-SA mit Zuschreibung
Nicht verbunden mit StackOverflow
scroll top