constructor ({
    div,
    htmlString,
    x,
    y,
    shownDialogBubbles,
    side,
    textDuration,
    zoom,
    width,
    id
  }) {
    this.id = `speech-${id}`
    const parser = new DOMParser()
    const html = parser.parseFromString(htmlString, 'text/html')

    const textDiv = html.body.firstChild
    const speechBubbleDiv = document.createElement('div')

    speechBubbleDiv.style.display = 'inline-block'
    speechBubbleDiv.style.position = 'absolute'

    if (typeof width === 'number') {
      speechBubbleDiv.style.maxWidth = `${width}vmin`
    } else {
      speechBubbleDiv.style.maxWidth = zoom === 2 ? SPEECH_BUBBLE_ZOOMED_MAX_WIDTH : SPEECH_BUBBLE_MAX_WIDTH
    }

    speechBubbleDiv.id = this.id
    speechBubbleDiv.className = `cinematic-speech-bubble-${side}`

    speechBubbleDiv.appendChild(textDiv)
    div.appendChild(speechBubbleDiv)

    // Calculate bounding box
    const bbox = speechBubbleDiv.getBoundingClientRect()
    const height = (bbox.bottom - bbox.top) + 2 * BUBBLE_PADDING

    // Set the origin for the left character speech bubble on the bottom left.
    // Set the origin for the right character speech bubble on the bottom right.
    y -= (height - BUBBLE_PADDING)
    if (side === 'right') {
      speechBubbleDiv.style.right = `${(WIDTH - x) / WIDTH * 100}%`
    } else if (side === 'center') {
      speechBubbleDiv.style.left = `calc( ${x / WIDTH * 100}% - 19vmin)`
    } else {
      speechBubbleDiv.style.left = `${x / WIDTH * 100}%`
    }

    speechBubbleDiv.style.top = `${y / HEIGHT * 100}%`

    const letters = (document.querySelectorAll(`#${this.id} .letter`) || []).length || 1
    if (textDuration === undefined) {
      textDuration = letters * LETTER_ANIMATE_TIME
    }
    if (me.get('aceConfig') && me.get('aceConfig').screenReaderMode) {
      // Render instantly when in screen reader mode; don't animate anything.
      textDuration = 0
    }

    this.resetSpeechBubble = () => {
      speechBubbleDiv.style.opacity = 0
      speechBubbleDiv.style.display = 'none'
      document.querySelectorAll(`#${this.id} .letter`)
        .forEach(letter => {
          letter.style.opacity = 0
        })
    }

    // We set up the animation but don't play it yet.
    // On completion we attach html node to the `shownDialogBubbles`
    // array for future cleanup.
    this.resetSpeechBubble()
    this.animationFn = () => {
      const numericalId = getIdFromElementId(this.id)
      store.dispatch('cinematicActionLog/changeCurrentPrompt', idTextMap[numericalId])
      shownDialogBubbles.push(speechBubbleDiv)
      this.resetSpeechBubble()
      return anime
        .timeline({
          autoplay: false
        })
        .add({
          targets: `#${this.id}`,
          opacity: 1,
          duration: 100,
          easing: 'easeInOutQuart',
          begin: () => {
            speechBubbleDiv.style.display = 'inline-block'
          }
        })
        .add({
          targets: `#${this.id} .letter`,
          opacity: 1,
          duration: 20,
          delay: anime.stagger(textDuration / (letters * 2), { easing: 'linear' }),
          easing: 'easeOutQuad'
        })
    }
  }
Creates a speech bubble eagerly.
Can return a command to display the speech bubble when called.

Attaches itself to the div canvas.