Je vous propose de découvrir ensemble comment créer un éditeur Markdown en utilisant du JavaScript et le framework Preact. En plus de Preact nous auront besoin de 2 librairies principales :

  • CodeMirror, qui est un outil qui permet de transformer un élément en éditeur de code . Il supporte différents langages de programmation mais supporte surtout le langage Markdown et nous permettra de colorer la syntaxe.
  • Marked servira à convertir le code Markdown en code HTML pour la partie prévisualisation.

L'utilisation du framework Preact nous permettra de mieux organiser notre code en réfléchissant sous forme de composant pour les différents éléments qui compose notre éditeur.

L'éditeur

Comme on l'a indiqué plus haut, nous allons utiliser la librairie CodeMirror pour la partie éditeur. Vu que cette librairie va modifier le code HTML il va falloir l'utiliser dans un composant qui ne se mettra pas à jour (afin de ne pas avoir de conflit avec les modifications du Virtual DOM).
On s'assurera que la méthode shouldComponentUpdate retourne false pour que le composant ne soit jamais mis à jour.

import {h, Component} from 'preact'
import CodeMirror from 'codemirror'
import 'codemirror/mode/markdown/markdown'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/neo.css'

export default class CodeMirrorComponent extends Component {

  render () {
    return <div/>
  }

  shouldComponentUpdate () {
    return false
  }

  componentDidMount () {
    let editor = CodeMirror(this.base, {
      value: this.props.value,
      mode: 'markdown',
      theme: 'neo',
      lineWrapping: true,
      viewportMargin: Infinity,
      cursorBlinkRate: 0
    })
    this.props.onReady(editor)
  }
}

Ce composant utilisera aussi une propriété onReady qui permettra de transférer l'instance de CodeMirror vers le composant parents. Ceci permettra aux autres composants de pouvoir interagir avec l'éditeur CodeMirror.

L'aperçu

La partie aperçu es,t quant à elle, beaucoup plus simple car elle n'aura besoin que d'une propriété qui sera le contenu Markdown. Le composant se chargera alors de générer le code HTML à l'aide de la librairie marked.

import {h, Component} from 'preact'
import marked from 'marked'

export default class Markdown extends Component {

  render (props) {
    return <div dangerouslySetInnerHTML={{__html: this.renderMarkdown()}}/>
  }

  renderMarkdown () {
    marked.setOptions({
      gfm: true,
      tables: true,
      breaks: true,
      pedantic: false,
      sanitize: true,
      smartLists: true,
      smartypants: false
    })
    return marked(this.props.markdown)
  }

}

Les boutons

Tous les boutons à l'intérieur de la barre d'outils vont avoir un comportement similaire. Afin d'éviter de se répéter nous allons utiliser une classe générique dont vont étendre tous nous autres composants.

import {h, Component} from 'preact'

export default class Button extends Component {

  shortcut = null

  componentDidMount () {
    if (this.shortcut !== null) {
      this.props.editor.setOption('extraKeys', {
        ...this.props.editor.getOption('extraKeys'),
        [this.shortcut]: () => {
          this.action(this.props.editor)
        }
      })
    }
  }

  render (props, state) {
    return <button onClick={this.onClick}>{this.icon(props, state)}</button>
  }

  icon () {
    return null
  }

  onClick = (e) => {
    e.preventDefault()
    this.action(this.props.editor)
  }

  action (editor) {
  }

}
  • shortcut, nous permettra de définir le raccourci clavier à utiliser pour déclencher l'action correspondant à ce bouton.
  • action(), permettra de définir l'opération à effectuer lorsque l'on cliquera sur le bouton ou lorsque l'on déclenchera le raccourci clavier.
  • icon() permettra de définir l'icône à afficher à l'intérieur de notre bouton.

Cette classe nous permet donc de créer des boutons beaucoup plus facilement. Par exemple pour le bouton "mise en gras" :

import {h} from 'preact'
import Button from './Button'

export default class BoldButton extends Button {

  shortcut = 'Ctrl-B'

  icon () {
    return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="..."/></svg>
  }

  action (editor) {
    editor.getDoc().replaceSelection('**' + editor.getDoc().getSelection() + '**')
    editor.focus()
  }

}

Le composant principal

Enfin, il nous reste plus qu'à combiner tout cela dans notre composant principal :

import {h, Component} from 'preact'
import CodeMirror from './CodeMirror'
import Markdown from './Markdown'
import linkstate from 'linkstate'
import './style'
import {Bold, Speech, Italic, Fullscreen} from './buttons'

export default class Editor extends Component {

  constructor (props) {
    super(props)
    this._sections = null
    this.scrolling = null
    this.state = {
      content: props.value,
      editor: null,
      fullscreen: false
    }
  }

  render ({name}, {content, editor, fullscreen}) {
    let cls = 'mdeditor'
    if (fullscreen === true) {
      cls += ' mdeditor--fullscreen'
    }
    return <div class={cls}>
      <div class="mdeditor__toolbar">
        <div className="mdeditor__toolbarleft">
          {editor && [
            <Bold editor={editor}/>,
            <Italic editor={editor}/>,
            <Speech editor={editor}/>,
          ]}
        </div>
        <div className="mdeditor__toolbarright">
          {editor && [
            <Fullscreen editor={editor} onFullscreen={linkstate(this, 'fullscreen')} fullscreen={fullscreen}/>,
          ]}
        </div>
      </div>
      <div class="mdeditor__editor">
        <CodeMirror value={content} onReady={this.setEditor}/>
      </div>
      <div class="mdeditor__preview">
        <Markdown markdown={content}/>
      </div>
      <textarea name={name} style="display:none;">{content}</textarea>
    </div>
  }

  componentDidUpdate (prevPops, prevState) {
    // Quand on passe en mode plein écran on rafraichit l'interface CodeMirror
    if (prevState.fullscreen !== this.state.fullscreen && this.state.editor) {
      this.state.editor.refresh()
    }
  }

  setEditor = (editor) => {
    this.setState({editor})
    editor.on('change', e => {
      this.setState({content: e.getDoc().getValue()})
    })
  }
}

Et si on souhaite monter votre composant sur un élément HTML standard, par exemple un textarea :

<textarea name="content" data-mdeditor>Du **Markdown**</textarea>
import { h, render } from 'preact'
import Editor from './editor'

let editors = document.querySelectorAll('[data-mdeditor]')

editors.forEach(function($textarea) {
  let $div = document.createElement('div')
  let value = $textarea.value
  let name = $textarea.getAttribute('name')
  if ($textarea.parentNode) {
    $textarea.parentNode.replaceChild($div, textarea)
    render(<Editor name={name} value={value}/>, $div)
  }
})