Tooltips

I'm using ‘tooltip’ to mean a small interface element hovering over the rest of the interface. These can be very useful in editors to show extra controls or information, for example as in a ‘Medium-style’ editing interface (named after the popular blogging platform), where most controls are hidden until you select something, at which point they pop up as a little bubble above the selection.

There are two common ways to implement tooltips in ProseMirror. The easiest is to insert widget decorations and absolutely position them, relying on the fact that if you don't specify an explicit position (as in a left or bottom property), such elements are positioned at the point in the document flow where they are placed. This works well for tooltips that correspond to a specific position.

If you want to position something above the selection, or you want to animate transitions, or you need to be able to allow the tooltips to stick out of the editor when the editor's overflow property isn't visible (for example to make it scroll), then decorations are probably not practical. In such a case, you'll have to ‘manually’ position your tooltips.

But you can still make use of ProseMirror's update cycle to make sure the tooltip stays in sync with the editor state. We can use a plugin view to create a view component tied to the editor's life cycle.

import {Plugin} from "prosemirror-state"

let selectionSizePlugin = new Plugin({
  view(editorView) { return new SelectionSizeTooltip(editorView) }
})

The actual view creates a DOM node to represent the tooltip and inserts it into the document alongside the editor.

class SelectionSizeTooltip {
  constructor(view) {
    this.tooltip = document.createElement("div")
    this.tooltip.className = "tooltip"
    view.dom.parentNode.appendChild(this.tooltip)

    this.update(view, null)
  }

  update(view, lastState) {
    let state = view.state
    // Don't do anything if the document/selection didn't change
    if (lastState && lastState.doc.eq(state.doc) &&
        lastState.selection.eq(state.selection)) return

    // Hide the tooltip if the selection is empty
    if (state.selection.empty) {
      this.tooltip.style.display = "none"
      return
    }

    // Otherwise, reposition it and update its content
    this.tooltip.style.display = ""
    let {from, to} = state.selection
    // These are in screen coordinates
    let start = view.coordsAtPos(from), end = view.coordsAtPos(to)
    // The box in which the tooltip is positioned, to use as base
    let box = this.tooltip.offsetParent.getBoundingClientRect()
    // Find a center-ish x position from the selection endpoints (when
    // crossing lines, end may be more to the left)
    let left = Math.max((start.left + end.left) / 2, start.left + 3)
    this.tooltip.style.left = (left - box.left) + "px"
    this.tooltip.style.bottom = (box.bottom - start.top) + "px"
    this.tooltip.textContent = to - from
  }

  destroy() { this.tooltip.remove() }
}

Whenever the editor state updates, it checks whether it needs to update the tooltip. The positioning calculations are a bit involved, but such is life with CSS. Basically, it uses ProseMirror's coordsAtPos method to find the screen coordinates of the selection, and uses those to set a left and bottom property relative to the tooltip's offset parent, which is the nearest absolutely or relatively positioned parent.