Feedback request on Footnotes

I’m trying a modified version of the Footnotes example and was hoping to get some feedback on my initial implementation before unleashing it on my users.

I’ve added a footnote node and a paragraph node per this excellent thread

const footnoteNodeSpec = {
  type: 'footnote',
  content: 'block+',
  group: 'inline',
  inline: true,
  defining: true,
  attrs: {
    expanded: {
      default: true
    }
  },
  parseDOM: [{
    tag: "footnote",
    getAttrs(dom) {
      return {
        expanded: dom.classList.contains("expanded")
      }
    }
  }],
  toDOM(node) {
    return ["footnote", {
      "class": node.attrs.expanded ? "expanded" : "collapsed",
      "title": "footnote"
    }, ["content", 0]]
  }
}
const paragraphNodeSpec = {
  content: 'inline*',
  group: 'block',
  parseDOM: [
    {tag: 'paragraph'},
    {tag: 'p'}
  ],
  toDOM() {
    return ['paragraph', 0];
  }
}

along with some basic styling:

.ProseMirror {
  counter-reset: prosemirror-footnote;
}

footnote {
  display: inline-block;
  vertical-align: text-top;
  background: #efefef;
  max-width: 60%;
  min-width: 1em;
  min-height: 1em;
  margin: 0 0.25em;
}

footnote footnotehead::before {
  content: "Footnote " counter(prosemirror-footnote);
  vertical-align: super;
  font-size: 75%;
  padding: 0 0.25em;
  counter-increment: prosemirror-footnote;
}

footnote.collapsed content {
  display: none;
}

footnote.expanded content {
  display: block;
  min-height: 1.1em;
}

paragraph {
  display: block;
  padding: 6px 12px;
}

and a custom NodeView

class FootnoteNodeView {
  constructor(node, view, getPos) {
    this.dom = window.document.createElement('footnote')

    if (node.attrs.expanded) {
      this.dom.classList.add('expanded')
    }
    else {
      this.dom.classList.add('collapsed')
      this.dom.contentEditable = false
    }
    let head = this.dom.appendChild(window.document.createElement('footnotehead'))
    head.contentEditable = false
    this.contentDOM = this.dom.appendChild(window.document.createElement('content'))
    head.addEventListener("click", e => {
      if (node.attrs.expanded) {
        view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {expanded: false}))
      }
      else {
        view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {expanded: true}))
      }

      e.preventDefault()
    })
  }
}

The biggest deviation from the example code is that I’ve traded the extra PM instance and management code for an expanded attribute on the footnote node – which feels janky, but works a treat in my testing. I may eventually have to keep track of the footnote id to better ease displaying them at the bottom of rendered documents with links between their actual location and their displayed content. I’m assuming that’ll involve a Plugin that looks for footnotes in transactions and updates their order as needed, but I’m saving that piece for after my initial implementation is proven sound. Any feedback on any of this is much appreciated.

I’m not entirely capable to judge your code, but regarding displaying footnotes at the bottom of the document: do you intend to do that within the editor? My two cents are that that would not result in a nice experience, as the user has to jump forth and back to the footnote and its location in the main content (like in Word documents, where they appear at the bottom of a page). I for myself want footnote texts to appear as near the corresponding main content as possible, to see which text I annotate or, possibly, comment. Of course this is my personal preference, and the whole point of my own project is that I don’t want to be distracted by layout issues, like exact places where my content appears, while typing my text. Your goals can be different.

Regarding the output to end users: in my application I parse the JSON-structure myself and generate HTML or TeX documents, heavily depending on other factors, like images containing separate caption texts (containing copyright holders to be displayed using the corresponding itemprop attribute). But output is a completely other topic, which I don’t think is at stake, here.

No. It displays inline

The public media company I work for has about 20 properties that are or will use this on their websites and podcast feeds. Some of them will do the tool-tip thing and others will split display using anchors to hop back and forth, and there are even some scenarios where we’ll render it as plain text. We have both ruby and react implementations of our display renderer library and while I know we could create the ids at render time I wonder if it’d be more performant/consistent to do it at creation time.

Huh, tried replacing that useless attribute with a node decoration and cannot get it to work at all. I can add a widget but not a node…

Oh duh, it’s an inline node, however when I create multiple inline decorations (with individual footnotes) and then try to use DecorationSet.find and DecorationSet.remove I get back an empty set.

Ok, after a brief trip to the implementation described in this thread I’ve changed to a much simpler implementation

For counting and renumbering footnotes

let findTheFoot = (state) => {
  let footnotes = []
  let counter = 0;
  let transaction = null
  state.doc.descendants((node, pos) => {
    if(node.type.name != 'footnote') return true
    counter = counter + 1
    if (node.attrs.number != counter) {
      if(transaction == null) transaction = state.tr
      transaction.setNodeMarkup(pos, null, { number: counter })
    }
  })
  return transaction
}

export function createFootNotesPlugin() {
  return new Plugin({
    key: 'footnotes',
    appendTransaction: (transactions, oldState, newState) => {
      return findTheFoot(newState)
    }
  })
}

The view:

class FootnoteNodeView {
  constructor(node, view, getPos) {
    this.dom = window.document.createElement('footnote')

    let head = this.dom.appendChild(window.document.createElement('footnotehead'))
    head.innerText = `Footnote ${node.attrs.number}`
    head.contentEditable = false
    this.contentDOM = this.dom.appendChild(window.document.createElement('content'))
    head.addEventListener("click", e => {
      if (this.dom.classList.contains('expanded')) {
        this.dom.classList.remove('expanded')
      }
      else {
        this.dom.classList.add('expanded')
      }
    })
  }
  ignoreMutation(rec) {
    return rec.target.tagName == 'FOOTNOTE' && rec.type == 'attributes' && rec.attributeName == 'class'
  }
}

export default FootnoteNodeView

I think I’ll need to tweak the header for accessibility purposes, but so far this works a treat.