legacy/outils/brouillon/Paint.js

import { j3pAddElt, j3pElement, j3pIsHtmlElement } from 'src/legacy/core/functions'

import 'src/legacy/outils/brouillon/paint.css'

class Paint {
  /**
   * Crée une zone pour dessiner
   * @param {Object} [options]
   * @param {string[]} [options.textes]
   * @param {string[]} [options.colors] Les colors à proposer, la dernière sera utilisée comme couleur de remplissage
   * @param {string|HTMLElement} container
   */
  constructor ({ textes = [], colors } = {}) {
    if (typeof this !== 'object') throw Error('Constructeur qui doit être appelé avec new')

    /**
     * Les lettres ou textes déplaçables
     * @type {string[]}
     */
    this.textes = [...textes]
    /** @type {string[]} */
    this.colors = Array.isArray(colors) ? [...colors] : ['#000000', '#FFFF00', '#FF0000', '#0000FF', '#00FF00', '#F0F0F0']
    // on prend la dernière comme couleur de remplissage
    /**
     * La couleur de remplissage
     * @type {string}
     */
    this.fillColor = this.colors.pop()
    // et la première comme couleur de trait
    this.currentColor = this.colors[0]
  } // constructor

  /**
   * Initialise le DOM
   * @param {Object} [options]
   * @param {number} [options.width=100]
   * @param {number} [options.height=100]
   * @param {number} [options.epaisseur=2]
   * @private
   */
  _init (container, { width = 100, height = 100, epaisseur = 2 }) {
    if (typeof container === 'string') container = j3pElement(container)
    if (!j3pIsHtmlElement(container)) throw Error('conteneur invalide')
    /**
     * Conteneur (position relative) qui sert de référence aux positionnements absolus (pour les div des lettres ou le tracé)
     * @type {HTMLElement}
     */
    this.container = j3pAddElt(container, 'div', '', { className: 'paint', style: { borderRadius: '2px', border: 'solid 2px', background: this.fillColor } })
    // on veut au moins 15px de haut pour les couleurs
    if (height < this.colors.length * 15 + 26) height = this.colors.length * 15 + 26
    this.canvas = j3pAddElt(this.container, 'canvas', { width: width - 50, height })
    const divColors = j3pAddElt(this.container, 'div', '', { className: 'colors' })
    const clickColorListener = this.onClickColor.bind(this)
    for (const color of this.colors) {
      const rect = j3pAddElt(divColors, 'div', '', { className: 'color', style: { backgroundColor: color } })
      if (color === this.colors[0]) rect.classList.add('selected')
      rect.addEventListener('click', clickColorListener)
    }
    // la dernière case est différente (bouton poubelle)
    const divTrash = j3pAddElt(divColors, 'div', '', { className: 'trashIcon' })
    divTrash.addEventListener('click', () => {
      // on efface tout
      this.ctx.fillStyle = this.fillColor
      // on retrace le rectangle
      this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
      this.ctx.strokeStyle = this.currentColor
    })

    this.ctx = this.canvas.getContext('2d')
    const paintStyles = getComputedStyle(this.container)
    this.canvas.width = (parseInt(paintStyles.getPropertyValue('width')) - 30)
    this.ctx.lineWidth = epaisseur
    this.ctx.lineJoin = 'round'
    this.ctx.lineCap = 'round'
    this.ctx.strokeStyle = this.colors[0]
    /**
     * listener attaché à l’instance pour pouvoir le mettre au mousedown et le retirer au mouseup|mouseout
     * @type {EventListener}
     */
    this.onDrawListener = this.onDraw.bind(this)
    // onMouseDown|onTouchStart va activer onDrawListener
    this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
    this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this))
    // faut le virer si ça sort du canvas
    this.canvas.addEventListener('mouseout', () => this.canvas.removeEventListener('mousemove', this.onDrawListener, { passive: true }))
    this.canvas.addEventListener('touchcancel', () => this.canvas.removeEventListener('touchmove', this.onDrawListener, { passive: true }))
    // ceinture & bretelles => on le vire aussi au mouseup|touchend sur document
    document.addEventListener('mouseup', () => this.canvas.removeEventListener('mousemove', this.onDrawListener, { passive: true }))
    // avec ça, on supprime le listener au 1er doigt "relevé", si y’en avait deux et qu’on en retire un ça coupe (sinon trop compliqué à gérer)
    document.addEventListener('touchend', () => this.canvas.removeEventListener('touchmove', this.onDrawListener, { passive: true }))

    this.divTextes = []
    let offset = 0.5
    for (const texte of this.textes) {
      const div = j3pAddElt(this.container, 'div', texte, { style: { cursor: 'pointer', position: 'absolute', zIndex: 1, left: '10px', top: `${offset}em` } })
      offset += 1
      const topCanvas = this.canvas.getBoundingClientRect().top
      const { top, height } = div.getBoundingClientRect()
      if (top - topCanvas + height > this.canvas.height) this.canvas.height = top - topCanvas + height + 10
      const onClickedTextListener = this.onClickedText.bind(this, div)
      div.addEventListener('mousedown', () => this.container.addEventListener('mousemove', onClickedTextListener, { passive: false }))
      div.addEventListener('touchstart', () => this.container.addEventListener('touchmove', onClickedTextListener, { passive: true }))
      document.addEventListener('mouseup', () => this.container.removeEventListener('mousemove', onClickedTextListener, { passive: false }))
      document.addEventListener('touchend', () => this.container.removeEventListener('touchmove', onClickedTextListener, { passive: true }))
      this.divTextes.push(div)
    }
  }

  /**
   * Change de couleur (mis en listener mousedown)
   * @param {MouseEvent} event
   */
  onClickColor (event) {
    // on change la couleur courante
    this.ctx.strokeStyle = event.currentTarget.style.backgroundColor
    // on vire les .selected déjà mis (normalement y’en a qu’un, mais…)
    for (const selected of this.container.querySelectorAll('.selected')) selected.classList.remove('selected')
    // et on le met sur la couleur cliquée
    event.currentTarget.classList.add('selected')
  }

  /**
   * Listener mousemove sur un texte
   * @param {HTMLElement} divTexte
   * @param {MouseEvent} event
   */
  onClickedText (divTexte, event) {
    // si c’est un événement souris faut du preventDefault pour éviter de sélectionner le texte autour
    if (!event.touches?.length) event.preventDefault()
    const { width, height } = divTexte.getBoundingClientRect()
    // on le déplace à l’endroit de la souris
    const { left, top, width: widthCanvas, height: heigthCanvas } = this.canvas.getBoundingClientRect()
    const { x, y } = this._getClientCoords(event)
    // si on sort, on devrait virer le listener mais ici on peut pas, on se contente de ne rien faire
    if (x < left + width / 2 || x > left + widthCanvas - width / 2 || y < top + height / 2 || y > top + heigthCanvas - height / 2) return
    divTexte.style.left = (x - left - width / 2) + 'px'
    divTexte.style.top = (y - top - height / 2) + 'px'
  }

  /**
   * Au mousedown sur canvas faut activer le listener move pour tracer
   * @param event
   */
  onMouseDown (event) {
    this.startDrawing(event)
    // et on active le tracé souris
    this.canvas.addEventListener('mousemove', this.onDrawListener, { passive: true })
  }

  /**
   * Au touch sur canvas faut activer le listener move pour tracer
   * @param event
   */
  onTouchStart (event) {
    this.startDrawing(event)
    // et on active le tracé touch
    this.canvas.addEventListener('touchmove', this.onDrawListener, { passive: true })
  }

  /**
   * Prépare ctx pour le tracé qui démarre
   * @param event
   */
  startDrawing (event) {
    // on déplace ctx
    this.ctx.beginPath()
    const { left, top } = this.container.getBoundingClientRect()
    // on mémorise ça, pour éviter de le recalculer à chaque move dans le canvas
    this.canvasLeft = left
    this.canvasTop = top
    this.ctx.moveTo(event.clientX - left, event.clientY - top)
  }

  /**
   * Retourne les coordonnées du pointeur (mouse ou touch)
   * @param {MouseEvent|TouchEvent} event
   * @return {{x, y}}
   * @private
   */
  _getClientCoords (event) {
    let x, y
    if (event.touches?.length) {
      // on prend toujours le premier touch, si y’a deux doigts (ou davantage) posé, on ignore les autres
      // (mais ce listener sera supprimé au 1er doigt relevé)
      x = event.touches[0].clientX
      y = event.touches[0].clientY
    } else {
      x = event.clientX
      y = event.clientY
    }
    return { x, y }
  }

  /**
   * Listener mis au mousedown|touchstart et retiré au mouseup|mouseout|touchend, il trace dans le canvas
   * @param {MouseEvent|TouchEvent} event
   */
  onDraw (event) {
    const { x, y } = this._getClientCoords(event)
    this.ctx.lineTo(x - this.canvasLeft, y - this.canvasTop)
    this.ctx.stroke()
  }

  /**
   * Crée une zone de dessin et la retourne
   * @param {HTMLElement|string} container
   * @param {Object} [options]
   * @param {number} [options.width=100]
   * @param {number} [options.height=100]
   * @param {number} [options.epaisseur=2]
   * @param {string[]} [options.textes] Les lettres ou textes déplaçables sur la zone de dessin
   * @param {string[]} [options.colors] Les colors à proposer, la dernière sera utilisée comme couleur de remplissage
   * @return {Paint}
   */
  static create (container, { width = 100, height = 100, epaisseur = 2, textes = [], colors } = {}) {
    const paint = new Paint({ textes, colors })
    paint._init(container, { width, height, epaisseur })
    return paint
  }
}

export default Paint