draftjs entity Content State to HTML

Viewed 150

I am using draftjs in which users can type in anything and can also click a button on which I am inserting a IMMUTABLE entity.

const text = "foo";

const editorState = this.state.value;
const selectionState = editorState.getSelection();
const contentState = editorState.getCurrentContent();
const contentStateWithEntity = contentState.createEntity("TOKEN", "IMMUTABLE", { time: new Date().getTime() });
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
const modifiedContent = Modifier.insertText(contentState, selectionState, text, OrderedSet([ "INSERT" ]), entityKey);
const nextState = EditorState.push( editorState, modifiedContent, editorState.getLastChangeType() );

this.setState({value: nextState}, this.focus );

https://codepen.io/dakridge/pen/XgLWJQ

All this is working fine but when the editor state is saved and now I am trying to render the HTML from the saved ContentState in my webpage then I am not able to identify the immutable entity and apply styles to it or render it differently.

For ex, in the above example how can foo be rendered with a different color and how the saved timestamp say can be logged in the console when I hover over foo?

I am using draftjs-to-html to render html from draftjs output.

2 Answers

If you want to track the timestamp (and other data of immutability) then your best option is to store the "raw" form instead of html (or keep alongside the html). You can use the convertToRaw to get the raw content, here's an example of foo inserted with timestamp as epoch.

{
  "entityMap": {
    "0": {
      "type": "TOKEN",
      "mutability": "SEGMENTED",
      "data": {
        "time": 1657116932641
      }
    }
  },
  "blocks": [
    {
      "key": "ekpc6",
      "text": "aaa",
      "type": "unstyled",
      "depth": 0,
      "inlineStyleRanges": [],
      "entityRanges": [],
      "data": {}
    },
    {
      "key": "dmkkr",
      "text": "foo",
      "type": "unstyled",
      "depth": 0,
      "inlineStyleRanges": [
        {
          "offset": 0,
          "length": 3,
          "style": "INSERT"
        }
      ],
      "entityRanges": [
        {
          "offset": 0,
          "length": 3,
          "key": 0
        }
      ],
      "data": {}
    }
  ]
};

I've taken your codepen code & added an explicit button to get raw content. Once you get the raw JSON, you can extract any meta data out.

const { Editor, EditorState, Modifier, convertToRaw, convertFromRaw } = Draft;
const { OrderedSet } = Immutable;

const sample_saved_state = {
  "entityMap": {
    "0": {
      "type": "TOKEN",
      "mutability": "SEGMENTED",
      "data": {
        "time": 1657116932641
      }
    }
  },
  "blocks": [
    {
      "key": "ekpc6",
      "text": "aaa",
      "type": "unstyled",
      "depth": 0,
      "inlineStyleRanges": [],
      "entityRanges": [],
      "data": {}
    },
    {
      "key": "dmkkr",
      "text": "foo",
      "type": "unstyled",
      "depth": 0,
      "inlineStyleRanges": [
        {
          "offset": 0,
          "length": 3,
          "style": "INSERT"
        }
      ],
      "entityRanges": [
        {
          "offset": 0,
          "length": 3,
          "key": 0
        }
      ],
      "data": {}
    }
  ]
};

class EditorComponent extends React.Component {
  
  constructor(props) {
    super(props);
    // In case you store the sample_saved_state as string in server side, you might have to do JSON.parse
    console.log("Converting from saved state");
    const saved_state = sample_saved_state != null ? EditorState.createWithContent(convertFromRaw(sample_saved_state))
                        : EditorState.createEmpty();
    console.log("Assining to  state ");
    this.state = {
      value : saved_state
    };
    
    this.saveContent = this.saveContent.bind(this);
    //this.state = { value: EditorState.createEmpty() };
    
    this.onChange = (value) => this.setState({value});
    this.focus = () => this.refs.editor.focus();
    
    this.insert = this.insert.bind( this );
    
  }
  
  insert () {
    
    const text = "foo";
    
    const editorState = this.state.value;
    const selectionState = editorState.getSelection();
    const contentState = editorState.getCurrentContent();
    const contentStateWithEntity = contentState.createEntity("TOKEN", "SEGMENTED", { time: new Date().getTime() });
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    const modifiedContent = Modifier.insertText(contentState, selectionState, text, OrderedSet([ "INSERT" ]), entityKey);
    const nextState = EditorState.push( editorState, modifiedContent, editorState.getLastChangeType() );
    
    this.setState({value: nextState}, this.focus );
  }
  saveContent () {
    console.log("inside saveContent");
    const content = convertToRaw(this.state.value.getCurrentContent());
    // you can save the content as json in server side
    console.log("Raw Content: ",content)
  }
  
  render () {

    return (
      <div>
        <div onClick={ this.focus } className="editor">
          <Editor 
            ref="editor" 
            onChange={ this.onChange } 
            editorState={ this.state.value } 
            customStyleMap={{ "INSERT": { backgroundColor: "yellow", padding: "0 2px" } }}
          />
        </div>
        
        <button onClick={ this.insert }>Insert</button>
        <button onClick={this.saveContent}>Save</button>
      </div>
    );
  }
  
}

ReactDOM.render(
  <EditorComponent />,
  document.getElementById('app')
);

Solution codesandbox: https://codesandbox.io/s/staging-fire-wo3oc5?file=/src/Editor.js

Instead of draftjs-to-html, I recommend draft-convert that comes with the functionality you want. You can customize the output HTML based on style and entity.

const html = convertToHTML({
  styleToHTML: (style) => {
    if (style === 'INSERT') {
      return <span style={{ backgroundColor: 'yellow', padding: '0 2px' }} />;
    }
  },
  entityToHTML: (entity) => {
    if (entity.type === 'TOKEN') {
      return <span data-time={entity.data.time} className="entity-token" />;
    }
  },
})(this.state.value.getCurrentContent());

For instance, I am wrapping nodes with the INSERT style inside a styling span, and nodes that correspond with the entity type TOKEN inside a functional span. This will result in an HTML node that looks like this:

enter image description here

Of course, you can combine these logic to wrap your token nodes inside 1 span only, if you want.

The reason we need functional spans is because handling interactions (like displaying time in console on hover) needs custom JavaScript, and we need to do this the old fashioned way. Where you render the HTML, just add the following effect hook:

useEffect(() => {
  const tokens = [
    ...containerRef.current.getElementsByClassName("entity-token")
  ];
  const handler = (event) => {
    const timestamp = event.target.dataset.time;
    console.log(timestamp);
  };

  tokens.forEach((token) => {
    token.addEventListener("mouseenter", handler);
  });

  return () => {
    tokens.forEach((token) => {
      token.removeEventListener("mouseenter", handler);
    });
  };
}, [content]);

This should handle the interactions for you. Sorry for mixing class components and functional components with hooks, but you can achieve the same thing with class components (using componentDidMount and componentDidUpdate) as well. Using hook is just more elegant.

Related