Tutoriel Vidéo TypeScript Preact Créer un éditeur Markdown en TypeScript

Télécharger la vidéo Télécharger les sources Voir la démo

Je vous propose de découvrir ensemble comment créer un éditeur Markdown en utilisant du TypeScript 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 * as CodeMirror from 'codemirror'
import 'codemirror/mode/markdown/markdown'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/neo.css'

interface IProps {
  value: string
  onReady: (editor: CodeMirror.Editor) => void
}

interface IState {

}

export default class CodeMirrorComponent extends Component<IProps,IState> {

  render (props: IProps, state: IState) {
    return <div></div>
  }

  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 * as marked from 'marked'

interface IProps {
  markdown: string
}

export default class MarkdownComponent extends Component<IProps,{}> {

  constructor (props: IProps) {
    super(props)
    marked.setOptions({
      gfm: true,
      tables: true,
      breaks: true,
      sanitize: true,
      smartLists: true,
      smartypants: false
    })
  }

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

  get html (): string {
    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'
import { Editor } from 'codemirror'

export interface ButtonProps {
  editor?: Editor
}

interface IState {

}

export class ButtonComponent<TProps extends ButtonProps = ButtonProps,TState = IState> extends Component<TProps,TState> {

  shortcut: null|string = null

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

  render (props?: TProps, state?: TState): JSX.Element {
    return <button onClick={this.onClick}>{this.icon()}</button>
  }

  private onClick = (e: MouseEvent): void => {
    e.preventDefault()
    this.action(this.props.editor)
  }

  icon (): null|JSX.Element {
    return null
  }

  action (editor?: Editor): void {
  }

}
  • 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 {ButtonComponent} from './ButtonComponent'
import { Editor } from 'codemirror'

export default class BoldButtonComponent extends ButtonComponent {

  shortcut = 'Ctrl-B'

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

  action (editor: 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 './CodeMirrorComponent'
import './style.scss'
import Markdown from './MarkdownComponent'
import {Editor} from 'codemirror'
import { Bold, Fullscreen, Italic, Speech } from './buttons'

interface IProps {
  value: string|null
  name: string|null
}

interface IState {
  content: string
  editor: Editor | null
  fullscreen: boolean
}

export default class EditorComponent extends Component<IProps,IState> {

  private $editor?: HTMLElement
  private $preview?: HTMLElement

  constructor (props: IProps) {
    super(props)
    this.state = {
      content: props.value || '',
      editor: null,
      fullscreen: false
    }
  }

  componentDidMount () {
    window.addEventListener('resize', this.resetSections)
  }

  componentWillUnmount () {
    window.removeEventListener('resize', this.resetSections)
  }

  componentDidUpdate (prevProps: IProps, prevState: IState) {
    if (prevState.fullscreen !== this.state.fullscreen) {
      if (this.state.editor) {
        this.state.editor.refresh()
      }
    }
    this.$editor = this.base.querySelector('.mdeditor__editor') as HTMLDivElement
    this.$preview = this.base.querySelector('.mdeditor__preview') as HTMLDivElement
  }

  render ({name}: IProps, {content, editor, fullscreen}: IState): JSX.Element {
    let cls = 'mdeditor'
    if (fullscreen) {
      cls += ' mdeditor--fullscreen'
    }
    return <div class={cls}>
      <div class="mdeditor__toolbar">
        <div class="mdeditor__toolbarleft">
          {editor && [
            <Bold editor={editor}/>,
            <Italic editor={editor}/>,
            <Speech editor={editor}/>,
          ]}
        </div>
        <div class="mdeditor__toolbarright">
          {editor && [
            <Fullscreen fullscreen={fullscreen} onClick={this.toggleFullscreen}/>
          ]}
        </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>
  }

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

  private toggleFullscreen = () => {
    this.setState({fullscreen: !this.state.fullscreen})
  }

}

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 './components/editor'

let textareas = document.querySelectorAll('textarea.mdeditor') as NodeListOf<HTMLTextAreaElement>

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

Gestion du scroll

Nous allons maintenant mettre en place le système de défilement synchronisé entre la prévisualisation et la partie éditeur. Le principal problème que l'on va rencontrer est que les deux parties n'ont pas des tailles constantes. En effet, une section dans la partie éditeur peut-être assez courte mais la version rendue peut être beaucoup plus longue avec l'intégration d'image par exemple.

Pour résoudre ce problème nous allons décomposer le contenu en sections et nous allons utiliser les titres pour les délimiter. Afin de simplifier un maximum le travail en amont nous allons créer une classe dédiée au calcul des sections d'un élément. Les sections seront représentés sous forme de tableau de tableau de taille 2 : [[0, 150], [150, 500] ...]

export type ISection = [number, number]
export type ISections = ISection[]

let selectors: string[] = []
for (let i = 1; i < 6; i++) {
  selectors.push(`h${i}`, `.cm-header-${i}`)
}

export class SectionsGenerator {

  static fromElement (element: HTMLElement): ISections {
    let titles = element.querySelectorAll(selectors.join(',')) as NodeListOf<HTMLElement>
    let start = 0
    let sections: ISections = []
    titles.forEach((title) => {
      let offsetTop = this.offsetTop(title, element)
      sections.push([start, offsetTop])
      start = offsetTop
    })
    sections.push([start, element.scrollHeight])
    return sections
  }

  static getScrollTop(y: number, sourceSections: ISections, targetSections: ISections): number {
    let index = this.getIndex(y, sourceSections)
    let source = sourceSections[index]
    let percentage = (y - source[0]) / (source[1] - source[0])
    let target = targetSections[index]
    return target[0] + percentage * (target[1] - target[0])
  }

  private static offsetTop (element: HTMLElement, target: HTMLElement, acc = 0): number {
    if (element === target) {
      return acc
    }
    return this.offsetTop(element.offsetParent as HTMLElement, target, acc + element.offsetTop)
  }

  private static getIndex (y: number, sections: ISections): number {
    return sections.findIndex(function (section) {
      return y >= section[0] && y <= section[1]
    })
  }

}

La première méthode fromElement va permettre de sélectionner tous les titres et d'en extraire leur position par rapport au haut de l'élément. Il faudra faire attention lorsque l'on récupérera la valeur offsetTop d'un élément car cette valeur est récupérée par rapport au premier parent qui n'est pas en position static. Pour obtenir un offsetTop par rapport à l'élément cible il faudra utiliser une fonction récursive (qui récupérera les valeurs scrollTop successives, jusqu'à remonter à l'élément qui sert de référence).

La méthode getIndex permet quant à elle de déterminer dans quelle section se situe le scroll. C'est une méthode qui sera utilisée en interne.

Enfin, la dernière méthode getScrollPosition constitue le cœur de notre logique. Elle prendra en paramètre la position du scroll, les sections de l'élément source, et les sections de l'élément cible. Cette méthode calculera automatiquement la position du scroll à appliquer à l'élément cible. Par exemple, si on a scrollé à 20 % de la 3ème section de l'éditeur, la fonction retournera la même position sur la partie prévisualisation.

Dans notre composant

Maintenant que cette classe est créée, nous allons pouvoir l'utiliser dans notre composant principal. Nous allons utiliser onScroll afin de détecter lorsque l'on scroll sur un élément et modifier ensuite la valeur scrolltop de l'élément correspondant.

Afin d'éviter un maximum de calcul, nous allons calculer les sections lors d'un redimensionnement de la fenêtre ou lors d'un changement de contenu.

De la même manière, il faudra récupérer l'élément que l'on est en train de faire défiler afin de ne pas déclencher en boucle les onScroll. On créera pour cela une propriété scrolling au niveau de notre élément qui prendra la valeur de l'élément qui est la cible du défilement. Cette valeur sera remise à zéro lorsque l'on stop le scroll à l'aide de la méthode debounce.

import {h,Component} from 'preact'
import CodeMirror from './CodeMirrorComponent'
import './style.scss'
import Markdown from './MarkdownComponent'
import {Editor} from 'codemirror'
import { Bold, Fullscreen, Italic, Speech } from './buttons'
import { ISections, SectionsGenerator } from './libs/SectionsGenerator'
import {debounce} from 'lodash'

interface IProps {
  value: string|null
  name: string|null
}

interface IState {
  content: string
  editor: Editor | null
  fullscreen: boolean
}

interface EditorSections {
  editor: ISections,
  preview: ISections,
  [key: string]: ISections
}

export default class EditorComponent extends Component<IProps,IState> {

  private sections: EditorSections|null = null
  private scrollingSection: string|null = null
  private $editor?: HTMLElement
  private $preview?: HTMLElement

  constructor (props: IProps) {
    super(props)
    this.state = {
      content: props.value || '',
      editor: null,
      fullscreen: false
    }
  }

  componentDidMount () {
    window.addEventListener('resize', this.resetSections)
  }

  componentWillUnmount () {
    window.removeEventListener('resize', this.resetSections)
  }

  componentDidUpdate (prevProps: IProps, prevState: IState) {
    if (prevState.fullscreen !== this.state.fullscreen) {
      if (this.state.editor) {
        this.state.editor.refresh()
      }
    }
    this.sections = null
    this.$editor = this.base.querySelector('.mdeditor__editor') as HTMLDivElement
    this.$preview = this.base.querySelector('.mdeditor__preview') as HTMLDivElement
  }

  render ({name}: IProps, {content, editor, fullscreen}: IState): JSX.Element {
    let cls = 'mdeditor'
    if (fullscreen) {
      cls += ' mdeditor--fullscreen'
    }
    return <div class={cls}>
      <div class="mdeditor__toolbar">...</div>
      <div class="mdeditor__editor" onScroll={this.onScroll}>
        <CodeMirror value={content} onReady={this.setEditor}/>
      </div>
      <div class="mdeditor__preview" onScroll={this.onScroll}>
        <Markdown markdown={content}/>
      </div>
      <textarea name={name || ''} style="display:none;">{content}</textarea>
    </div>
  }

  private resetSections = () => {
    this.sections = null
  }

  private getSections (): EditorSections|null {
    if (this.sections === null && this.$editor && this.$preview) {
      this.sections = {
        editor: SectionsGenerator.fromElement(this.$editor),
        preview: SectionsGenerator.fromElement(this.$preview)
      }
    }
    return this.sections
  }

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

  private toggleFullscreen = () => {
    this.setState({fullscreen: !this.state.fullscreen})
  }

  private onScroll = (e: UIEvent) => {
    let sections = this.getSections()
    if (sections === null) {
      return
    }
    let eventTarget = e.target as HTMLDivElement
    let source = eventTarget === this.$editor ? 'editor' : 'preview'
    if (this.scrollingSection === null) {
      this.scrollingSection = source
    } else if (this.scrollingSection !== source) {
      return
    }
    let target = source === 'editor' ? 'preview' : 'editor'
    let $source = eventTarget
    let $target = eventTarget === this.$editor ? this.$preview : this.$editor
    let scrollTop = SectionsGenerator.getScrollTop($source.scrollTop, sections[source], sections[target])
    if ($target) {
      $target.scrollTop = scrollTop
    }
    this.resetScrolling()
  }

  private resetScrolling = debounce(() => {
    this.scrollingSection = null
  }, 500)

}