Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CustomOption to add HTML #772

Open
MickaelBZH opened this issue Jan 21, 2019 · 15 comments
Open

CustomOption to add HTML #772

MickaelBZH opened this issue Jan 21, 2019 · 15 comments

Comments

@MickaelBZH
Copy link

Hello,

I'm trying to get a custom option in the toolbar to add HTML (here a specific image).

import {stateToHTML} from 'draft-js-export-html';
import {stateFromHTML} from 'draft-js-import-html';

class StickerOption extends React.Component {
 
  addStar = () => {
    const { editorState, onChange } = this.props;
    EditorState.moveFocusToEnd(editorState);
    let currentHTML = stateToHTML(editorState.getCurrentContent());
    currentHTML =   currentHTML + '<img src="myimage" />';
    let contentState = stateFromHTML(currentHTML);
    onChange(EditorState.push(editorState, contentState, 'insert-fragment'));
  };

  render() {
    return (
      <div onClick={this.addStar}>⭐</div>
    );
  }
}

Any idea to get this work?
There is an image option in the toolbar, so I guess it should also be possible to add an image programmatically.

@jpuri
Copy link
Owner

jpuri commented Jan 29, 2019

@Huespal
Copy link

Huespal commented Jan 31, 2019

Hello.

I'm also trying to add HTML directly to the Editor. @MickaelBZH, did you came with a solution?
I added a button like this answer: #315 (comment)

But when I add html in the 'code' view, it gets lost. div's are converted to p's, etc.

My final goal is to add html code to not be converted, just returned to the editor the same way (and maybe replaced with some 'there is custom html here' indicator).

Any advises @jpuri will be appreciated :D

Thank you all.

@jpuri
Copy link
Owner

jpuri commented Feb 1, 2019

Hey @Huespal , Are you trying to embed some HTML section in editor, may be a custom block can help you there.

Or are you trying to set text of editor using the HTML ? For that it is required to use draftjs api to generate editor content.

@Huespal
Copy link

Huespal commented Feb 1, 2019

Hello.

Maybe second one. I already have an embed button to add videos, for example. But what I'm trying to do is to give the user the possibility to append html, wrote by him/her, without being converted to br's or p', etc. For example adding div's or script's. I'm playing with draftJS API :), with no results :(, at the moment.

Thanks

@jpuri
Copy link
Owner

jpuri commented Feb 1, 2019

Hey @Huespal , users will not be able to append html block / inline styles not supported by the editor :(
Only option is a custom block type, that is also not so straightforward.

@Huespal
Copy link

Huespal commented Feb 4, 2019

Ok, I get a solution that suits my problem :D

I added a fantastic 'code view' button:

render() {
const { editorCode } = this.state;
return (<Editor
    [...]
     blockRendererFn={this.blockRenderer}
     onEditorStateChange={this.onEditorStateChange}
    toolbarCustomButtons={[
        [...],
        <CodeOption toggleEditorCode={this.toggleEditorCode} /> // See more info to know how this should work.
    ]}
   />
   {
      showEditorCode
        && (
          <textarea
            id="code-view"
            name="codeView"
            value={editorCode}
            onChange={this.onEditorCodeStateChange}
          />
        )
    });
}

More info: #315 (comment)

Played with blockRendererFn:

blockRenderer(contentBlock) {
    const { editorState } = this.state;

    const type = contentBlock.getType();

    if (type === 'atomic') {
      const contentState = editorState.getCurrentContent();
      const entityKey = contentBlock.getEntityAt(0);
      if (entityKey) {
        const entity = contentState.getEntity(entityKey);
        if (entity
          && (entity.type === 'SCRIPT'
            || entity.type === 'DIV')
        ) {
          return {
            component: () => '</>', // Or whatever you like.
            editable: false
          };
        }
      }
    }
    return null;
  }
}

And made some magic with customEntityTransform parameter in draftToHtml, and customChunkRenderer parameter in htmlToDraft() function;

customChunkRenderer(nodeName, node) {
    if (nodeName === 'div') {
      return {
        type: 'DIV',
        mutability: 'MUTABLE',
        data: { // Pass whatever you want here (like id, or classList, etc.)
          innerText: node.innerText,
          innerHTML: node.innerHTML
        }
      };
    }
    if (nodeName === 'script') {
      return {
        type: 'SCRIPT',
        mutability: 'MUTABLE',
        data: { // Pass whatever you want here (like id, or keyEvents, etc.)
          innerText: node.innerText,
          innerHTML: node.innerHTML
        }
      };
    }
    return null;
  }


onEditorCodeStateChange(editorCode) {
    let editorState = EditorState.createEmpty();

    const blocksFromHtml = htmlToDraft(
      editorCode,
      customChunkRenderer
    );

    if (blocksFromHtml) {
      const { contentBlocks, entityMap } = blocksFromHtml;
      const contentState = ContentState.createFromBlockArray(contentBlocks, entityMap);
      editorState = EditorState.createWithContent(contentState);
    }

    this.setState({
      editorState,
      editorCode
    });
  }

onEditorStateChange(editorState) {
    const editorCode = draftToHtml(
      convertToRaw(editorState.getCurrentContent()),
      null,
      null,
      (entity) => {
        if (entity.type === 'DIV') { // Receive what you passed before, here (like id, or classList, etc.)
          return `<div>${entity.data.innerHTML}</div>`;
        }
        if (entity.type === 'SCRIPT') { // Receive what you passed before, here (like id, or keyEvents, etc.)
          return `<script>${entity.data.innerHTML}</script>`;
        }
        return '';
      }
    );

    this.setState({
      editorState,
      editorCode
    });

}

It's so cool to allow people to add html to WYSWYG :D

@sachinkammar
Copy link

@Huespal I'm bit confused! would you mind providing the whole component code?

@Huespal
Copy link

Huespal commented Mar 20, 2019

@sachinkammar
You can see all code in the fork on my profile.
It is not totally working :/
I'm struggling with uploaded images not being renderer. Any help is appreciated.

@claytonrothschild
Copy link

Where did you land on this @Huespal ? I could not find a fork in your profile.

@elvizcacho
Copy link

elvizcacho commented Jul 23, 2019

@Huespal Your approach is fine but it doesn't work for nested tags. e.g for a table. I made some changes to make it work for divs and tables. Check my solution. (It was written on TypeScript but the same applies to JS)

import React from 'react'
import { Editor as Draft } from 'react-draft-wysiwyg'
import { ColorPalette } from '@allthings/colors'
import htmlToDraft from 'html-to-draftjs'
import { EditorState, ContentState, convertToRaw } from 'draft-js'
import draftToHtml from 'draftjs-to-html'
import { css } from 'glamor'

const styles = {
  editorStyle: invalid => ({
    backgroundColor: ColorPalette.white,
    border: `1px solid ${ColorPalette[invalid ? 'red' : 'lightGreyIntense']}`,
    borderRadius: '2px',
    maxHeight: '30vh',
    minHeight: '300px',
    overflowY: 'auto',
    padding: '5px',
    width: 'inherit',
  }),
  toolbarStyle: {
    backgroundColor: ColorPalette.lightGrey,
    border: `1px solid ${ColorPalette.lightGreyIntense}`,
    borderBottom: '0px none',
    marginBottom: '0px',
    marginTop: '5px',
    width: 'inherit',
  },
  wrapperStyle: {},
}

interface IProps {
  editorState: any
  invalid?: boolean
  onEditorStateChange: (state) => void
  toolbar?: object
  onChange?: (editorState) => void
}

interface IState {
  showEditorCode: boolean
  editor: any
  editorHTML: any
  textareaEditor: any
  showCode: boolean
}

function customChunkRenderer(nodeName, node) {
  const allowedNodes = [
    'div',
    'table',
    'tbody',
    'tr',
    'th',
    'td',
    'thead',
    'style',
  ]

  if (allowedNodes.includes(nodeName)) {
    return {
      type: nodeName.toString().toUpperCase(),
      mutability: 'MUTABLE',
      data: {
        // Pass whatever you want here (like id, or classList, etc.)
        innerText: node.innerText,
        innerHTML: node.innerHTML,
      },
    }
  }
  return null
}

function entityMapper(entity) {
  if (entity.type === 'DIV') {
    return `<div>${entity.data.innerHTML}</div>`
  }
  if (entity.type === 'TABLE') {
    return `<table>${entity.data.innerHTML}</table>`
  }
  if (entity.type === 'TBODY') {
    return `<tbody>${entity.data.innerHTML}</tbody>`
  }
  if (entity.type === 'TR') {
    return `<tr>${entity.data.innerHTML}</tr>`
  }
  if (entity.type === 'TH') {
    return `<th>${entity.data.innerHTML}</th>`
  }
  if (entity.type === 'TD') {
    return `<td>${entity.data.innerHTML}</td>`
  }
  if (entity.type === 'STYLE') {
    return `<style>${entity.data.innerHTML}</style>`
  }
  return ''
}

function entityMapperToComponent(entity) {
  if (entity.type === 'DIV') {
    return () => (
      <div dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
    )
  }
  if (entity.type === 'TABLE') {
    return () => (
      <table dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
    )
  }
  if (entity.type === 'TBODY') {
    return <tbody dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
  }
  if (entity.type === 'TR') {
    return () => (
      <tr dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
    )
  }
  if (entity.type === 'TH') {
    return () => (
      <th dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
    )
  }
  if (entity.type === 'TD') {
    return () => (
      <td dangerouslySetInnerHTML={{ __html: entity.data.innerHTML }} />
    )
  }
  if (entity.type === 'STYLE') {
    return () => <style>{entity.data.innerHTML}</style>
  }

  return ''
}

function customBlockRenderFunc(block, config) {
  if (block.getType() === 'atomic') {
    const contentState = config.getEditorState().getCurrentContent()
    const entity = contentState.getEntity(block.getEntityAt(0))
    return {
      component: entityMapperToComponent(entity),
      editable: false,
      props: {
        children: () => entity.innerHTML,
      },
    }
  }
  return undefined
}

export default class Editor extends React.Component<IProps, IState> {
  constructor(props) {
    super(props)
    this.state = {
      showEditorCode: false,
      editor: EditorState.createEmpty(),
      editorHTML: '',
      textareaEditor: '',
      showCode: false,
    }
  }

  onEditorStateChange = editor => {
    const editorHTML = draftToHtml(
      convertToRaw(editor.getCurrentContent()),
      null,
      false,
      entityMapper,
    )
    this.setState({ editor, editorHTML })
  }

  onEditEditorHTML = e => {
    const editorHTML = e.target.value
    this.setState({ editorHTML })
  }

  toggleEditorCode = () => {
    const { showEditorCode } = this.state
    const { editorState } = this.props
    if (!showEditorCode) {
      this.onEditorStateChange(editorState)
    }
    this.setState({ showEditorCode: !showEditorCode })
  }

  addHtmlToEditor = () => {
    const { editorHTML } = this.state

    const contentBlock = htmlToDraft(editorHTML, customChunkRenderer)
    let editor
    if (contentBlock) {
      const contentState = ContentState.createFromBlockArray(
        contentBlock.contentBlocks,
      )
      editor = EditorState.createWithContent(contentState)
    } else {
      editor = EditorState.createEmpty()
    }
    this.props.onChange(editor)
  }

  render() {
    const {
      editorState,
      invalid = false,
      onEditorStateChange,
      toolbar,
      intl,
    } = this.props

    const { showEditorCode, editorHTML } = this.state

    const ShowEditorCode = () => (
      <div className="rdw-option-wrapper" onClick={this.toggleEditorCode}>
        {showEditorCode
          ? intl.formatMessage(MESSAGES.hideCode)
          : intl.formatMessage(MESSAGES.showCode)}
      </div>
    )

    return (
      <div>
        <Draft
          editorState={editorState}
          editorStyle={styles.editorStyle(invalid)}
          name="content"
          onEditorStateChange={onEditorStateChange}
          toolbar={toolbar}
          toolbarStyle={styles.toolbarStyle}
          wrapperStyle={styles.wrapperStyle}
          toolbarCustomButtons={[<ShowEditorCode />]}
          customBlockRenderFunc={customBlockRenderFunc}
        />
        {showEditorCode && (
          <div {...css({ width: '100%' })}>
            <textarea
              rows={10}
              {...css({
                width: '100%',
                padding: '0',
              })}
              value={editorHTML}
              onChange={this.onEditEditorHTML}
            />
            <div>
              <button type="button" onClick={this.addHtmlToEditor}>
                Submit
              </button>
            </div>
          </div>
        )}
      </div>
    )
  }
}

Bildschirmfoto 2019-07-23 um 09 23 01

@elvizcacho
Copy link

I improve the code and separete the helper functions as shown below:

draft-js-helpers.tsx

import { Parser as HtmlToReactParser } from 'html-to-react'
import DOMPurify from 'dompurify'
const allowedNodes = ['div', 'table', 'style', 'img']

const styleObjToCSS = styleObj =>
  Object.keys(styleObj).reduce((acum, style) => {
    return (style && styleObj[style]
      ? `${style}:${styleObj[style]}; ${acum}`
      : ''
    ).trim()
  }, '')

const nodeAttributesToObj = attrs => {
  const objAttrs = { style: null }
  for (let i = attrs.length - 1; i >= 0; i--) {
    if (attrs[i].name !== 'style') {
      if (attrs[i].name && attrs[i].value) {
        objAttrs[attrs[i].name] = attrs[i].value
      }
    } else {
      const stylesInText = attrs[i].value.split(';')
      const styles = stylesInText.reduce((acum, style) => {
        const components = style.split(':')
        if (components[0] && components[1]) {
          acum[components[0]] = `${components[1]}`
        }
        return acum
      }, {})
      objAttrs.style = styles
    }
  }
  return objAttrs
}

export function entityMapper(entity) {
  let type = entity.type
  let data = { ...entity.data }

  if (type === 'IMAGE') {
    // added to support the existing image option in the editor
    type = 'IMG'
    data = { attributes: data, innerHTML: '' }
  }

  data.attributes = data.attributes ? data.attributes : {}
  let styleAsAttribute
  if (data.attributes.style) {
    styleAsAttribute = styleObjToCSS(data.attributes.style)
  }

  const attributes = Object.keys(data.attributes).reduce(
    (acum, key) =>
      (key === 'style'
        ? `${key}="${styleAsAttribute}" ${acum}`
        : `${key}="${data.attributes[key]}" ${acum}`
      ).trim(),
    '',
  )

  const node = type.toLowerCase()
  if (allowedNodes.includes(node)) {
    return `<${node} ${attributes}>${data.innerHTML}</${node}>`
  }
  return ''
}

export function entityMapperToComponent(entity) {
  const htmlToReactParser = new HtmlToReactParser()
  return () => htmlToReactParser.parse(DOMPurify.sanitize(entityMapper(entity)))
}

export function customChunkRenderer(nodeName, node) {
  if (allowedNodes.includes(nodeName)) {
    let objAttrs = {}

    if (node.hasAttributes()) {
      objAttrs = nodeAttributesToObj(node.attributes)
    }

    return {
      type: nodeName.toString().toUpperCase(),
      mutability: 'MUTABLE',
      data: {
        // Pass whatever you want here (like id, or classList, etc.)
        innerText: node.innerText,
        innerHTML: node.innerHTML,
        attributes: objAttrs,
      },
    }
  }
  return null
}

Editor.tsx

import React from 'react'
import { Editor as Draft } from 'react-draft-wysiwyg'
import { ColorPalette } from '@allthings/colors'
import htmlToDraft from 'html-to-draftjs'
import { EditorState, ContentState, convertToRaw } from 'draft-js'
import draftToHtml from 'draftjs-to-html'
import { css } from 'glamor'
import {
  entityMapperToComponent,
  customChunkRenderer,
  entityMapper,
} from 'utils/draft-js-helpers'

const styles = {
  ....
}

interface IProps {
  color?: string
  editorState: any
  invalid?: boolean
  onEditorStateChange: (state) => void
  toolbar?: object
  onChange?: (editorState) => void
}

interface IState {
  showEditorCode: boolean
  editorHTML: string
  showCode: boolean
}

function customBlockRenderFunc(block, config) {
  if (block.getType() === 'atomic') {
    const contentState = config.getEditorState().getCurrentContent()
    const entity = contentState.getEntity(block.getEntityAt(0))
    return {
      component: entityMapperToComponent(entity),
      editable: false,
      props: {
        children: () => entity.innerHTML,
      },
    }
  }
  return undefined
}

class Editor extends React.Component<IProps & InjectedIntlProps, IState> {
  constructor(props) {
    super(props)
    this.state = {
      showEditorCode: false,
      editorHTML: '',
      showCode: false,
    }
  }

  onEditorStateChange = editor => {
    const editorHTML = draftToHtml(
      convertToRaw(editor.getCurrentContent()),
      null,
      false,
      entityMapper,
    )
    this.setState({ editorHTML })
  }

  onEditEditorHTML = ({ target: { value: editorHTML } }) =>
    this.setState({ editorHTML })

  toggleEditorCode = () => {
    const { showEditorCode } = this.state
    const { editorState } = this.props
    if (!showEditorCode) {
      this.onEditorStateChange(editorState)
    }
    this.setState({ showEditorCode: !showEditorCode })
  }

  addHtmlToEditor = () => {
    const { editorHTML } = this.state

    const contentBlock = htmlToDraft(editorHTML, customChunkRenderer)
    let editor
    if (contentBlock) {
      const contentState = ContentState.createFromBlockArray(
        contentBlock.contentBlocks,
      )
      editor = EditorState.createWithContent(contentState)
    } else {
      editor = EditorState.createEmpty()
    }
    this.props.onChange(editor)
  }

  render() {
    const {
      editorState,
      invalid = false,
      onEditorStateChange,
      toolbar,
      intl,
      color,
    } = this.props

    const { showEditorCode, editorHTML } = this.state

    const ShowEditorCode = () => (
      <div className="rdw-option-wrapper" onClick={this.toggleEditorCode}>
        {showEditorCode
          ? 'Hide Code'
          : 'Show Code'
      </div>
    )

    return (
      <>
        <Draft
          editorState={editorState}
          editorStyle={styles.editorStyle(invalid)}
          name="content"
          onEditorStateChange={onEditorStateChange}
          toolbar={toolbar}
          toolbarStyle={styles.toolbarStyle}
          wrapperStyle={styles.wrapperStyle}
          toolbarCustomButtons={[<ShowEditorCode />]}
          customBlockRenderFunc={customBlockRenderFunc}
        />
        {showEditorCode && (
          <div {...css({ width: '100%' })}>
            <textarea
              rows={10}
              {...css({
                width: '100%',
                padding: '0',
              })}
              value={editorHTML}
              onChange={this.onEditEditorHTML}
            />
            <div>
              <button
                type="button"
                onClick={this.addHtmlToEditor}
              >
                Submit
              </Button>
            </div>
          </div>
        )}
      </>
    )
  }
}

@krvikash35
Copy link

I improve the code and separete the helper functions as shown below:

draft-js-helpers.tsx

import { Parser as HtmlToReactParser } from 'html-to-react'
import DOMPurify from 'dompurify'
const allowedNodes = ['div', 'table', 'style', 'img']

const styleObjToCSS = styleObj =>
  Object.keys(styleObj).reduce((acum, style) => {
    return (style && styleObj[style]
      ? `${style}:${styleObj[style]}; ${acum}`
      : ''
    ).trim()
  }, '')

const nodeAttributesToObj = attrs => {
  const objAttrs = { style: null }
  for (let i = attrs.length - 1; i >= 0; i--) {
    if (attrs[i].name !== 'style') {
      if (attrs[i].name && attrs[i].value) {
        objAttrs[attrs[i].name] = attrs[i].value
      }
    } else {
      const stylesInText = attrs[i].value.split(';')
      const styles = stylesInText.reduce((acum, style) => {
        const components = style.split(':')
        if (components[0] && components[1]) {
          acum[components[0]] = `${components[1]}`
        }
        return acum
      }, {})
      objAttrs.style = styles
    }
  }
  return objAttrs
}

export function entityMapper(entity) {
  let type = entity.type
  let data = { ...entity.data }

  if (type === 'IMAGE') {
    // added to support the existing image option in the editor
    type = 'IMG'
    data = { attributes: data, innerHTML: '' }
  }

  data.attributes = data.attributes ? data.attributes : {}
  let styleAsAttribute
  if (data.attributes.style) {
    styleAsAttribute = styleObjToCSS(data.attributes.style)
  }

  const attributes = Object.keys(data.attributes).reduce(
    (acum, key) =>
      (key === 'style'
        ? `${key}="${styleAsAttribute}" ${acum}`
        : `${key}="${data.attributes[key]}" ${acum}`
      ).trim(),
    '',
  )

  const node = type.toLowerCase()
  if (allowedNodes.includes(node)) {
    return `<${node} ${attributes}>${data.innerHTML}</${node}>`
  }
  return ''
}

export function entityMapperToComponent(entity) {
  const htmlToReactParser = new HtmlToReactParser()
  return () => htmlToReactParser.parse(DOMPurify.sanitize(entityMapper(entity)))
}

export function customChunkRenderer(nodeName, node) {
  if (allowedNodes.includes(nodeName)) {
    let objAttrs = {}

    if (node.hasAttributes()) {
      objAttrs = nodeAttributesToObj(node.attributes)
    }

    return {
      type: nodeName.toString().toUpperCase(),
      mutability: 'MUTABLE',
      data: {
        // Pass whatever you want here (like id, or classList, etc.)
        innerText: node.innerText,
        innerHTML: node.innerHTML,
        attributes: objAttrs,
      },
    }
  }
  return null
}

Editor.tsx

import React from 'react'
import { Editor as Draft } from 'react-draft-wysiwyg'
import { ColorPalette } from '@allthings/colors'
import htmlToDraft from 'html-to-draftjs'
import { EditorState, ContentState, convertToRaw } from 'draft-js'
import draftToHtml from 'draftjs-to-html'
import { css } from 'glamor'
import {
  entityMapperToComponent,
  customChunkRenderer,
  entityMapper,
} from 'utils/draft-js-helpers'

const styles = {
  ....
}

interface IProps {
  color?: string
  editorState: any
  invalid?: boolean
  onEditorStateChange: (state) => void
  toolbar?: object
  onChange?: (editorState) => void
}

interface IState {
  showEditorCode: boolean
  editorHTML: string
  showCode: boolean
}

function customBlockRenderFunc(block, config) {
  if (block.getType() === 'atomic') {
    const contentState = config.getEditorState().getCurrentContent()
    const entity = contentState.getEntity(block.getEntityAt(0))
    return {
      component: entityMapperToComponent(entity),
      editable: false,
      props: {
        children: () => entity.innerHTML,
      },
    }
  }
  return undefined
}

class Editor extends React.Component<IProps & InjectedIntlProps, IState> {
  constructor(props) {
    super(props)
    this.state = {
      showEditorCode: false,
      editorHTML: '',
      showCode: false,
    }
  }

  onEditorStateChange = editor => {
    const editorHTML = draftToHtml(
      convertToRaw(editor.getCurrentContent()),
      null,
      false,
      entityMapper,
    )
    this.setState({ editorHTML })
  }

  onEditEditorHTML = ({ target: { value: editorHTML } }) =>
    this.setState({ editorHTML })

  toggleEditorCode = () => {
    const { showEditorCode } = this.state
    const { editorState } = this.props
    if (!showEditorCode) {
      this.onEditorStateChange(editorState)
    }
    this.setState({ showEditorCode: !showEditorCode })
  }

  addHtmlToEditor = () => {
    const { editorHTML } = this.state

    const contentBlock = htmlToDraft(editorHTML, customChunkRenderer)
    let editor
    if (contentBlock) {
      const contentState = ContentState.createFromBlockArray(
        contentBlock.contentBlocks,
      )
      editor = EditorState.createWithContent(contentState)
    } else {
      editor = EditorState.createEmpty()
    }
    this.props.onChange(editor)
  }

  render() {
    const {
      editorState,
      invalid = false,
      onEditorStateChange,
      toolbar,
      intl,
      color,
    } = this.props

    const { showEditorCode, editorHTML } = this.state

    const ShowEditorCode = () => (
      <div className="rdw-option-wrapper" onClick={this.toggleEditorCode}>
        {showEditorCode
          ? 'Hide Code'
          : 'Show Code'
      </div>
    )

    return (
      <>
        <Draft
          editorState={editorState}
          editorStyle={styles.editorStyle(invalid)}
          name="content"
          onEditorStateChange={onEditorStateChange}
          toolbar={toolbar}
          toolbarStyle={styles.toolbarStyle}
          wrapperStyle={styles.wrapperStyle}
          toolbarCustomButtons={[<ShowEditorCode />]}
          customBlockRenderFunc={customBlockRenderFunc}
        />
        {showEditorCode && (
          <div {...css({ width: '100%' })}>
            <textarea
              rows={10}
              {...css({
                width: '100%',
                padding: '0',
              })}
              value={editorHTML}
              onChange={this.onEditEditorHTML}
            />
            <div>
              <button
                type="button"
                onClick={this.addHtmlToEditor}
              >
                Submit
              </Button>
            </div>
          </div>
        )}
      </>
    )
  }
}

it is working wonderfully but now i am not getting the LCR alignment option on hover of image. I want to align image Left right center, could you please help resolve this issue. Thanks for the above snippet.

@manoharkumarsingh
Copy link

i am using above code but style tag is not supporting, it is automatically removing <style> tag.

@manoharkumarsingh
Copy link

import {
Button,
ButtonGroup,
Card,
Classes,
FormGroup,
IButtonProps,
Menu,
MenuItem,
Popover,
Position,
Tooltip
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { IWidgetProps } from "decorator-form";
import {
CompositeDecorator,
ContentState,
convertFromHTML,
convertFromRaw,
convertToRaw,
DefaultDraftBlockRenderMap,
EditorState,
getDefaultKeyBinding,
KeyBindingUtil,
Modifier,
RichUtils
} from "draft-js";
import React, { KeyboardEvent } from "react";
import { TextAreaWidget } from "common/form/widgets/TextAreaWidget";
import { Editor } from "react-draft-wysiwyg";
import "./editor.css";
import { composeDecorators } from "draft-js-plugins-editor";
import IOption from "common/models/IOption";
import ButtonSelect from "../common/ButtonSelect";
import DropdownSelect from "../common/DropdownSelect";
import createFocusPlugin from "draft-js-focus-plugin";
import createAlignmentPlugin from "draft-js-alignment-plugin";
import { stateToHTML } from "draft-js-export-html";
import htmlToDraft from "html-to-draftjs";
import draftToHtml from "draftjs-to-html";
import {
entityMapperToComponent,
customChunkRenderer,
entityMapper
} from "./helper";

// https://www.pngall.com/wp-content/uploads/8/Sample-PNG-Image.png
/**

  • Rich text editor widget that creates an HTML string.
  • WARNING
  • This does not do any internal sanitization of the content.
  • Be sure to sanitize server side if the content is being
  • saved with a mutation that does not have admin role restrictions.
  • @author chris
    */

const inlineStyleOptions: IOption[] = [
{ value: "BOLD", label: "Bold", icon: IconNames.BOLD },
{ value: "ITALIC", label: "Italic", icon: IconNames.ITALIC },
{ value: "UNDERLINE", label: "Underline", icon: IconNames.UNDERLINE }
];

const blockStyleOptions: IOption[] = [
{ value: "unstyled", label: "Normal" },
{ value: "header-one", label: "Heading 1" },
{ value: "header-two", label: "Heading 2" },
{ value: "header-three", label: "Heading 3" },
{ value: "header-four", label: "Heading 4" },
{ value: "header-five", label: "Heading 5" },
{ value: "header-six", label: "Heading 6" }
];

const blockTypeOptions: IOption[] = [
{
value: "unordered-list-item",
label: "Unordered List",
icon: IconNames.PROPERTIES
},
{
value: "ordered-list-item",
label: "Ordered List",
icon: IconNames.NUMBERED_LIST
},
{ value: "blockquote", label: "Blockquote", icon: IconNames.CITATION }
];

const IS_DRAFT_JS = true;

const decorator = new CompositeDecorator([
// link
{
strategy: (contentBlock, callback, contentState) => {
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === "LINK"
);
}, callback);
},
component: (props: any) => {
const { contentState, entityKey } = props;
const { url } = contentState.getEntity(entityKey).getData();
return (

{props.children}

);
}
}

// TODO template variable

]);

interface IRichTextWidgetProps extends IWidgetProps {
argNames?: string[];

// optional appearance modifiers
minimal?: boolean;
small?: boolean;
large?: boolean;

}

interface IRichTextWidgetState {
editorState: EditorState;
isOpen: boolean;
htmlVal?: string;
}
function customBlockRenderFunc(block, config) {
if (block.getType() === "atomic") {
const contentState = config.getEditorState().getCurrentContent();
const entity = contentState.getEntity(block.getEntityAt(0));
return {
component: entityMapperToComponent(entity),
editable: false,
props: {
children: () => entity.innerHTML
}
};
}
return undefined;
}

const getHtml = editorState =>
draftToHtml(convertToRaw(editorState.getCurrentContent()));
export default class RichTextWidgdet extends React.Component<
IRichTextWidgetProps,
IRichTextWidgetState

{
private timer: any;
focusPlugin = createFocusPlugin();
alignmentPlugin = createAlignmentPlugin();

decorator = composeDecorators(
	this.alignmentPlugin.decorator,
	this.focusPlugin.decorator
);

plugins = [this.focusPlugin, this.alignmentPlugin];

picker: any;
getEditorState: () => EditorState;
updateEditorState: (editorState: EditorState) => void;
editor: Editor;

public constructor(props: IRichTextWidgetProps) {
	super(props);

	let editorState: EditorState;
	if (props.value && props.value !== "null") {
		if (this.isRaw(props.value)) {
			const DBEditorState = convertFromRaw(
				JSON.parse(this.props.value)
			);
			editorState = EditorState.createWithContent(
				DBEditorState,
				decorator
			);
		} else {
			//to keep <br> tag
			const blockRenderMap = DefaultDraftBlockRenderMap.set("br", {
				element: "br"
			});

			const fromHtml = htmlToDraft(props.value, customChunkRenderer);

			if (
				fromHtml &&
				fromHtml.contentBlocks &&
				fromHtml.contentBlocks.length
			) {
				editorState = EditorState.createWithContent(
					ContentState.createFromBlockArray(
						fromHtml.contentBlocks,
						fromHtml.entityMap
					),
					decorator
				);
			}
		}
	}

	if (!editorState) {
		editorState = EditorState.createEmpty(decorator);
	}

	this.state = { editorState, htmlVal: "", isOpen: false };
}

private isRaw(str: string) {
	try {
		return JSON.parse(str) && !!str;
	} catch (e) {
		return false;
	}
}

focus = () => {
	this.editor.focus();
};

public render() {
	return (
		<Card>
			<div style={{ height: "auto" }}>
				<Editor
					editorState={this.state.editorState}
					wrapperClassName="rich-editor demo-wrapper"
					editorClassName="demo-editor"
					onEditorStateChange={this.onChange}
					placeholder="The message goes here..."
					toolbarCustomButtons={[this.renderToolbar()]}
					keyBindingFn={this.bindKeyCommands}
					handleKeyCommand={this.handleKeyCommand}
					toolbar={{
						options: ["colorPicker", "image"]
					}}
					customBlockRenderFunc={customBlockRenderFunc}
				/>
			</div>
		</Card>
	);
}

/**
 * render editor controls toolbar as a blueprint button group
 */
private renderToolbar = () => {
	const { editorState } = this.state;

	const selection = editorState.getSelection();
	const currentBlockType = this.getCurrentBlockType();
	const currentStyle = editorState.getCurrentInlineStyle();
	const currentHasLink = RichUtils.currentBlockContainsLink(editorState);

	const buttonProps: IButtonProps = {
		small: this.props.small,
		large: this.props.large
	};

	const controls: React.ReactNodeArray = [];

	// block type
	controls.push(
		<DropdownSelect
			key="block-type"
			buttonProps={buttonProps}
			onChange={this.toggleBlockType}
			options={blockStyleOptions}
			value={currentBlockType}
		/>
	);

	// block style
	controls.push(
		<ButtonSelect
			key="block-style"
			buttonProps={buttonProps}
			noGroup={true}
			iconOnly={true}
			onChange={this.toggleBlockType}
			options={blockTypeOptions}
			value={currentBlockType}
		/>
	);

	// inline style
	for (const o of inlineStyleOptions) {
		controls.push(
			<Tooltip
				content={o.label}
				key={o.value}
				position={Position.TOP}
			>
				<Button
					{...buttonProps}
					active={currentStyle.has(o.value)}
					icon={o.icon}
					onClick={() => this.toggleInlineStyle(o.value)}
				/>
			</Tooltip>
		);
	}

	// add/change link
	controls.push(
		<Button
			{...buttonProps}
			key="link-add"
			icon="link"
			disabled={
				selection.getEndOffset() - selection.getStartOffset() < 1
			}
			active={currentHasLink}
			onClick={this.addLink}
		/>
	);

	// variables
	if (this.props.argNames && this.props.argNames.length > 0) {
		controls.push(this.renderVariableControls());
	}

	// undo button
	controls.push(
		<Button
			key="undo"
			{...buttonProps}
			icon="undo"
			onClick={this.undo}
			disabled={!editorState.getAllowUndo()}
		/>
	);

	// redo button
	controls.push(
		<Button
			key="redo"
			{...buttonProps}
			icon="redo"
			onClick={this.redo}
			disabled={editorState.getRedoStack().size < 1}
		/>
	);

	// html button
	controls.push(
		<Popover
			key="html-add"
			isOpen={this.state.isOpen}
			content={
				<div style={{ width: "500px" }}>
					<TextAreaWidget
						value={this.state.htmlVal}
						onChange={this.handleHtmlChange}
					/>
				</div>
			}
		>
			<Tooltip content="Edit as HTML" position={Position.TOP}>
				<Button
					icon={IconNames.CODE}
					active={this.state.isOpen}
					onMouseDown={this.toggleHtmlEditor}
				/>
			</Tooltip>
		</Popover>
	);
	const { minimal = true } = this.props;

	return (
		<FormGroup>
			<ButtonGroup minimal={minimal}>{controls}</ButtonGroup>
		</FormGroup>
	);
};

/**
 * all content-changing methods should call this instead of setState directly
 * so that it will trigger the debounced onChange.
 */
private onChange = (editorState: EditorState) => {
	if (editorState) {
		this.setState({ editorState }, () => {
			if (!!this.timer) {
				clearTimeout(this.timer);
			}
			this.timer = setTimeout(() => {
				if (IS_DRAFT_JS) {
					let html = draftToHtml(
						convertToRaw(editorState.getCurrentContent()),
						null,
						false,
						entityMapper
					);
					this.props.onChange(html);
				} else {
					let html = draftToHtml(
						convertToRaw(editorState.getCurrentContent()),
						null,
						false,
						entityMapper
					);
					this.props.onChange(html);
				}
			}, 200);
		});

		const editorHTML = draftToHtml(
			convertToRaw(editorState.getCurrentContent()),
			null,
			false,
			entityMapper
		);
		this.props.onChange(editorHTML);

		// const d = this.state.editorState.getCurrentContent();
		// const a = stateToHTML(this.state.editorState.getCurrentContent());
		// this.setState({ editorState }, () => {
		// 	if (!!this.timer) {
		// 		clearTimeout(this.timer);
		// 	}
		// 	this.timer = setTimeout(() => {
		// 		if (IS_DRAFT_JS) {
		// 			let html = JSON.stringify(
		// 				convertToRaw(
		// 					this.state.editorState.getCurrentContent()
		// 				)
		// 			);
		// 			this.props.onChange(html);
		// 		} else {
		// 			this.props.onChange(
		// 				draftToHtml(
		// 					this.state.editorState.getCurrentContent()
		// 				)
		// 			);
		// 		}
		// 	}, 200);
		// });
	}
};

private undo = () => {
	const { editorState } = this.state;
	if (editorState.getAllowUndo()) {
		this.onChange(EditorState.undo(editorState));
	}
};

private redo = () => {
	const { editorState } = this.state;
	if (editorState.getRedoStack().size > 0) {
		this.onChange(EditorState.undo(this.state.editorState));
	}
};

private toggleHtmlEditor = () => {
	if (!this.state.isOpen) {
		let html = draftToHtml(
			convertToRaw(this.state.editorState.getCurrentContent()),
			null,
			false,
			entityMapper
		);
		this.setState({
			isOpen: true,
			htmlVal: html
			// htmlVal: getHtml(this.state.editorState)
		});
	} else {
		const blocksFromHtml = htmlToDraft(
			this.state.htmlVal,
			customChunkRenderer
		);
		const { contentBlocks, entityMap } = blocksFromHtml;
		const contentState = ContentState.createFromBlockArray(
			contentBlocks,
			entityMap
		);
		const editorState = EditorState.createWithContent(contentState);
		console.log(
			"editor state from html editor",
			JSON.stringify(editorState)
		);
		this.setState({ isOpen: false });
		this.onChange(editorState);
	}
};

private handleHtmlChange = val => {
	{
		this.setState({ htmlVal: val });
	}
};

private renderVariableControls = (): JSX.Element => {
	return (
		<Popover
			key="vars-add"
			content={
				<Menu>
					{this.props.argNames.map(name => (
						<MenuItem
							key={name}
							text={name}
							onClick={this.insertVariable(name)}
						/>
					))}
				</Menu>
			}
		>
			<Tooltip
				content="Insert Template Variables"
				position={Position.TOP}
			>
				<Button icon={IconNames.INSERT} />
			</Tooltip>
		</Popover>
	);
};

private getCurrentBlockType = (): string => {
	const selection = this.state.editorState.getSelection();
	return this.state.editorState
		.getCurrentContent()
		.getBlockForKey(selection.getStartKey())
		.getType();
};

private toggleBlockType = (type: string) => {
	this.onChange(RichUtils.toggleBlockType(this.state.editorState, type));
};

private toggleInlineStyle = (inlineStyle: string) => {
	this.onChange(
		RichUtils.toggleInlineStyle(this.state.editorState, inlineStyle)
	);
};

private addLink = () => {
	const { editorState } = this.state;
	const selection = editorState.getSelection();
	const url = window.prompt("Enter Link");
	if (!url) {
		this.onChange(RichUtils.toggleLink(editorState, selection, null));
		return;
	}
	const withLink = editorState
		.getCurrentContent()
		.createEntity("LINK", "MUTABLE", { url, target: "_blank" });
	const nextState = EditorState.push(
		editorState,
		withLink,
		"apply-entity"
	);
	const linkKey = withLink.getLastCreatedEntityKey();
	this.onChange(RichUtils.toggleLink(nextState, selection, linkKey));
	return;
};

private handleKeyCommand = (command: string) => {
	if (command === "text-bold") {
		this.toggleInlineStyle("BOLD");
		return "handled";
	}

	if (command === "text-italic") {
		this.toggleInlineStyle("ITALIC");
		return "handled";
	}

	if (command === "text-underline") {
		this.toggleInlineStyle("UNDERLINE");
		return "handled";
	}

	if (command === "link") {
		this.addLink();
		return "handled";
	}

	return "not-handled";
};

private bindKeyCommands = (e: KeyboardEvent) => {
	if (KeyBindingUtil.hasCommandModifier(e)) {
		// bold
		if (e.keyCode === 66) {
			return "text-bold";
		}

		// italic
		if (e.keyCode === 73) {
			return "text-italic";
		}

		// underline
		if (e.keyCode === 85) {
			return "text-underline";
		}

		// link
		if (e.keyCode === 75) {
			return "add-link";
		}
	}

	return getDefaultKeyBinding(e);
};

private insertVariable = (name: string) => () => {
	const { editorState } = this.state;

	const currentContent = editorState.getCurrentContent();
	const selection = editorState.getSelection();

	this.onChange(
		EditorState.push(
			editorState,
			Modifier.insertText(
				currentContent,
				selection,
				"${" + name + "}",
				null
			),
			"insert-characters"
		)
	);
};

}

@manoharkumarsingh
Copy link

import { Parser as HtmlToReactParser } from "html-to-react";
import DOMPurify from "dompurify";
const allowedNodes = ["div", "table", "style", "img"];

const styleObjToCSS = styleObj =>
Object.keys(styleObj).reduce((acum, style) => {
return (style && styleObj[style]
? ${style}:${styleObj[style]}; ${acum}
: ""
).trim();
}, "");

const nodeAttributesToObj = attrs => {
const objAttrs = { style: null };
for (let i = attrs.length - 1; i >= 0; i--) {
if (attrs[i].name !== "style") {
if (attrs[i].name && attrs[i].value) {
objAttrs[attrs[i].name] = attrs[i].value;
}
} else {
const stylesInText = attrs[i].value.split(";");
const styles = stylesInText.reduce((acum, style) => {
const components = style.split(":");
if (components[0] && components[1]) {
acum[components[0]] = ${components[1]};
}
return acum;
}, {});
objAttrs.style = styles;
}
}
return objAttrs;
};

export function entityMapper(entity) {
let type = entity.type;
let data = { ...entity.data };

if (type === "IMAGE") {
	// added to support the existing image option in the editor
	type = "IMG";
	data = { attributes: data, innerHTML: "" };
}

data.attributes = data.attributes ? data.attributes : {};
let styleAsAttribute;
if (data.attributes.style) {
	styleAsAttribute = styleObjToCSS(data.attributes.style);
}

const attributes = Object.keys(data.attributes).reduce(
	(acum, key) =>
		(key === "style"
			? `${key}="${styleAsAttribute}" ${acum}`
			: `${key}="${data.attributes[key]}" ${acum}`
		).trim(),
	""
);

const node = type.toLowerCase();
if (allowedNodes.includes(node)) {
	return `<${node} ${attributes}>${data.innerHTML}</${node}>`;
}
return "";

}

export function entityMapperToComponent(entity) {
const htmlToReactParser = new HtmlToReactParser();
return () =>
htmlToReactParser.parse(DOMPurify.sanitize(entityMapper(entity)));
}

export function customChunkRenderer(nodeName, node) {
if (allowedNodes.includes(nodeName)) {
let objAttrs = {};

	if (node.hasAttributes()) {
		objAttrs = nodeAttributesToObj(node.attributes);
	}

	return {
		type: nodeName.toString().toUpperCase(),
		mutability: "MUTABLE",
		data: {
			// Pass whatever you want here (like id, or classList, etc.)
			innerText: node.innerText,
			innerHTML: node.innerHTML,
			attributes: objAttrs
		}
	};
}
return null;

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants