Draft-js: Update SelectionState on readOnly Editor component

Created on 27 Sep 2016  ·  8Comments  ·  Source: facebook/draft-js

Do you want to request a _feature_ or report a _bug_?
Feature request, I presume.

What is the current behavior?
When the Editor component is set to readOnly, no events are fired at all (e.g. the onChange method is ignored). This seems to be by design: “Set whether the editor should be rendered as static DOM, with all editability disabled.” However, not even selection events are triggered.

What is the expected behavior?
My use case: I want to render non-editable text within an Editor component (taking advantage of the Entity rendering), but still get an updated SelectionState on each new selection to display a tooltip near the selected text + save the current selection to a Redux store. I can implement this easily without the readOnly attribute:
ezgif com-video-to-gif
But once I set the readOnly attribute, I have no way of retrieving the current selection from the Editor model. My current workaround is not to set the attribute but instead discard all editor changes apart from selection state updates.

Would be nice to be able to keep onSelect on the Editor component while disabling all editing with readOnly, like requested in #467 .

// in constructor
this.onChange = editorState => {
  if (this.state.editorState.getCurrentContent() != editorState.getCurrentContent()) {
      this.setState( EditorState.undo(editorState) );
  } else {
      this.props.dispatch(/* action to save raw ContentState + SelectionState to Redux store */);
      this.setState( EditorState.undo(editorState) );
  }
}  

Which versions of Draft.js, and which browser / OS are affected by this issue? Did this work in previous versions of Draft.js?
Working with Draft.js 0.9.1, macOS 10.12 + Google Chrome 53.0.2785.116. Didn't work previously afaik.

Most helpful comment

I was able to solve this with a bit of a hack:

In read only mode:

<Editor
    keyBindingFn={() => 'not-handled-command'}
/>

Basically this causes the Editor to return a custom 'not-handled-command' for all input including backspace, pasting etc. https://draftjs.org/docs/advanced-topics-key-bindings.html

Then Draft js looks for this not-handled-command in the handleKeyCommand prop. Since we don't pass a custom handleKeyCommand to handle this custom command, nothing seems to change. However selecting stuff still calls the onChange handler in draft-js so I am able to get onChange events to fire even in this 'readOnly' mode

All 8 comments

How about this

class MyComponent extends Component {
  state = { editable: false }
  handleBeforeInput = () => this.state.editable ? 'unhandled' : 'handled'
  render() {
    return (
        <Editor handleBeforeInput={this.handleBeforeInput} />
    );
  }
}

Blocks input, but doesn't block other modifiers such as backspace, delete, cut, paste.

so, that solution didn't help.

what did it for me was this:

  • go back to using the readOnly flag.
  • forget about selections in draft (they are not only not reported via onChange, but the selection is not changed in the EditorState.
  • use window.getSelection().getRangeAt(0) and dig through the start and end containers until it has the block keys and offsets (relative to the block's dom tree).
  • construct a SelectionState from that, and don't bother draft with the fact that we just selected something.
        var leftSiblingLength = function(node) {
            if (node === null) {
                return 0;
            } else {
                return textLength(node) + leftSiblingLength(node.previousSibling);
            }
        };

        var textLength = function(node) {
            if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
                return (node.textContent || node.innerText).length;
            } else {
                return 0;
            }
        };

        var getKeyFromBlock = function(node) {
            /* <div data-block="true" data-editor="c3ddk" data-offset-key="5n4ph-0-0">... */
            if (node.attributes['data-block'] && node.attributes['data-block'].value  === 'true') {
                var [key, x, y] = node.attributes['data-offset-key'].value.split('-');
                if (x !== '0' || y !== '0') {
                    console.log(node, node.attributes['data-offset-key']);
                    throw "unexpected block key value";
                }
                return key;
            }
        };

        var iterate = function(node, offset) {
            // out of parents (happens if point lies outside of editor_ component).
            if (!node) {
                return {};
            }

            // no block key - move to parent with current offset plus text length of left siblings.
            var blockkey = getKeyFromBlock(node);
            if (!blockkey) {
                return iterate(node.parentElement, offset + leftSiblingLength(node.previousSibling));
            }

            // block key found - return with current offset.
            return {
                "_selectionBlock": blockkey,
                "_selectionOffset": offset
            };
        };

        var mkPoint = function(container, offset) {
            return iterate(container.parentElement, offset + leftSiblingLength(container.previousSibling));
        };

        var sel        = getSelection();
        var range      = sel.getRangeAt(0);
        var backward   = sel.anchorNode !== range.startContainer;
        var startpoint = mkPoint(range.startContainer, range.startOffset);
        var endpoint   = mkPoint(range.endContainer, range.endOffset);

        return { "_selectionIsBackward": backward,
                 "_selectionStart": startpoint,
                 "_selectionEnd": endpoint
               };

not pretty, but reasonably maintainable, i think.

I was able to solve this with a bit of a hack:

In read only mode:

<Editor
    keyBindingFn={() => 'not-handled-command'}
/>

Basically this causes the Editor to return a custom 'not-handled-command' for all input including backspace, pasting etc. https://draftjs.org/docs/advanced-topics-key-bindings.html

Then Draft js looks for this not-handled-command in the handleKeyCommand prop. Since we don't pass a custom handleKeyCommand to handle this custom command, nothing seems to change. However selecting stuff still calls the onChange handler in draft-js so I am able to get onChange events to fire even in this 'readOnly' mode

@terencechow Just want to say thank you for your fix as outside of readOnly mode, I am able to trigger the inline-toolbar plugin, now going to create a comment button!

For someone who finding this solution, the hack from @terencechow is fully block the most of characters, but sadly the composition input method such as Chinese or Japanese could not work at all, and the handleBeforeInput also could not catch any of them.

Unless I'm missing something, this solution doesn't prevent anyone from altering the text via the right mouse button (using "paste", or even "delete") ?

Edit : Ok I missed something :

It turns out something has changed between version 0.10.0 and 0.10.5 (I don't realy know where, I just tried both), and now Editor component won't change in any way unless those changes are implemented through your functions (the ones you define for the upper level component). It meens that in this case you don't really need the readOnly property neither this hack, you just have to define your own onChange handler function, to make it find the selectionState without altering the contentState.

Since for some reasons (that I don't fully understand myself) the selectionState doesn't exist before you reset the component state, and since you want the onChange handler to take care only of the selectionState, one easy (dumb ?) solution would be to use two states : the normal inalterable editorState, and a selectorState, which is always redefined from the editorState.

For example :

class Reader extends Component {
  constructor(props) {
    super(props);
    this.state = {
      editorState: EditorState.createWithContent(convertFromRaw(testContent)),
      selectorState: EditorState.createWithContent(convertFromRaw(testContent))
    }
    this.handleSelector = (selectorState) => this.setState({selectorState});
    this.logSelection = () => console.log(this.state.selectorState.getSelection());
  }

  render() {
    return(
    <div>
      <button onClick={this.logSelection} />
      <Editor
        editorState={this.state.editorState}
        onChange={this.handleSelector}
      />
    </div>
    )
  }
}

The following is working for me:

Don't use the readOnly prop.

The basic idea is using EditorState.forceSelection, but with the new selectionState applied to a reference editorState that never gets updated by onChange.

// in constructor() (or fn to update based on props change)
const editorState = EditorState.create...
// IMPORTANT set editorState whenever you set referenceState
this.state = { editorState, referenceState: editorState } // or setState

// and then the onChange function:
onChange(editorState) {
  const selectionState = editorState.getSelection()
  const startKey = selectionState.getStartKey()
  const startOffset = selectionState.getStartOffset()

  // IMPORANT if you allow multiple blocks in your editor component
  const endKey = selectionState.getEndKey()
  const endOffset = selectionState.getEndOffset()

  // what selection data is important to you?
  const highlight = `${startKey}-${startOffset}-${endKey}-${endOffset}`

  // don't force selection unnecessarily
  // or you'll never be able to blur
  // because otherwise every onChange would call forceSelection below
  // (and clicking anywhere on the page when this editor is focused runs onChange)

  if (highlight === this.state.highlight) {
    // just keep the existing editor state (funny behavior without this setState)
    return this.setState({ editorState: this.state.editorState })
  }

  this.setState({
    /* *** apply the new selectionState to the referenceState *** */
    editorState: EditorState.forceSelection(
      this.state.referenceState,
      selectionState,
    ),
    // keep track of relevant selection data
    highlight,
  })
}

Obviously the use of the highlight variable here is a very minimal approach and is just an example; you'll want to do something that suits what you're doing with the selection data (eg, implementing highlighting).

Also note that you may want to update referenceState based on props changes, but that shouldn't change anything here.

Also note you might want to use anchor/focus instead of start/end, depending on what you're doing.

Was this page helpful?
0 / 5 - 0 ratings