Tutoriel Vidéo Preact Créer un éditeur Markdown, Synchroniser le scroll

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

Dans cette seconde partie nous allons 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] ...]

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

export default class SectionsGenerator {

  static fromElement (element) {
    let matches = element.querySelectorAll(selectors.join(', '))
    let previous = 0
    let sections = []
    matches.forEach(title => {
      let offsetTop = this.offsetTop(title, element)
      sections.push([previous, offsetTop])
      previous = offsetTop
    })
    sections.push([previous, element.scrollHeight])
    return sections
  }

  static offsetTop (element, target, acc = 0) {
    if (element === target) {
      return acc
    }
    return this.offsetTop(element.offsetParent, target, acc + element.offsetTop)
  }

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

  static getScrollPosition (y, sourceSections, targetSections) {
    let index = this.getIndex(y, sourceSections)
    let section = sourceSections[index]
    let percentage = (y - section[0]) / (section[1] - section[0])
    let targetSection = targetSections[index]
    return targetSection[0] + percentage * (targetSection[1] - targetSection[0])
  }

}

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 './CodeMirror'
import Markdown from './Markdown'
import linkstate from 'linkstate'
import './style'
import SectionsGenerator from '../libs/SectionsGenerator'
import debounce from 'lodash/debounce'

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>
      <div class="mdeditor__editor" onScroll={this.onScroll('editor')}>
        <CodeMirror value={content} onReady={this.setEditor}/>
      </div>
      <div class="mdeditor__preview" onScroll={this.onScroll('preview')}>
        <Markdown markdown={content}/>
      </div>
      <textarea name={name} style="display:none;">{content}</textarea>
    </div>
  }

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

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

  componentDidUpdate (prevPops, prevState) {
    let sections = SectionsGenerator.fromElement(this.base.querySelector('.mdeditor__editor'))
    if (prevState.content !== this.state.content) {
      this._sections = null
    }
    if (prevState.fullscreen !== this.state.fullscreen) {
      this.state.editor.refresh()
    }
    this.resetSections()
  }

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

  resetSections = () => {
    this._sections = null
  }

  get sections () {
    if (this._sections === null) {
      this._sections = {
        editor: SectionsGenerator.fromElement(this.base.querySelector(`.mdeditor__editor`)),
        preview: SectionsGenerator.fromElement(this.base.querySelector(`.mdeditor__preview`))
      }
    }
    return this._sections
  }

  onScroll (source) {
    return (e) => {
      if (this.scrolling === null) {
        this.scrolling = source
      }
      if (this.scrolling !== source) {
        return false
      }
      let target = source === 'preview' ? 'editor' : 'preview'
      let scrollTop = SectionsGenerator.getScrollPosition(
        e.target.scrollTop,
        this.sections[source],
        this.sections[target]
      )
      this.base.querySelector(`.mdeditor__${target}`).scrollTop = scrollTop
      this.resetScrolling()
    }
  }

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

}