Reference the same node in two different locations

This is an odd use case, but I’ve finally managed a working footnotes implementation (splitting the difference between prosemirror website example and a bit of how fidus writer went about it).

Because I’ve got multiple output targets I have multiple ways of displaying footnotes:

  1. Inline with popups
  2. At the bottom of the doc with anchor links (or just as plain text)

I’m solving this now by providing a footnote list node at the end of the document that wraps a copy of all of the footnotes in the doc. This gets generated by a plugin on the fly after every edit. Which seems like it will eventually get spendy performance-wise.

I think what I need to do is keep track of the position refs in the plugin state and then do some fancy mapping on updates to update my bottom footnotes based on those positions, but I’m not sure how to deal efficiently with adding/removing footnotes. I also wonder if there’s a better way to store a ref to the existing nodes at the bottom and only copy their content at serialization time.

here’s my current plugin:

// Search out all Footnote nodes and make sure they're numbered correctly
// Store all of them in a hidden Footnotes node we can use on the rendering side
// Keep the ruby and react Amat implementations as dumb as we can.
let findTheFoot = (state) => {
  let counter = 0;
  let transaction = null
  let footNotesPos = null
  let footnotes = []
  state.doc.descendants((node, pos) => {
    let stashNode = node
    if(node.type.name == 'apm_footnote_list') {
      footNotesPos = pos

      // do not iterate into this node
      // which will have copies of every footnote
      return false
    }

    if(node.type.name != 'apm_footnote') return true
    counter = counter + 1

    if (node.attrs.number != counter) {
      if(transaction == null) transaction = state.tr
      let newAttrs = Object.assign({}, node.attrs, { number: counter } )
      transaction.setNodeMarkup(pos, null, newAttrs)

      // The transaction above won't be applied to our footnote yet when
      // we add it to the footnotes node below
      stashNode = state.config.schema.nodes.apm_footnote.create(newAttrs, node.content)
    }

    footnotes.push(stashNode)
    return true;
  })

  if (footnotes.length) {
    if(transaction == null) transaction = state.tr
    let newFooters = state.config.schema.nodes.apm_footnote_list.create({}, footnotes)

    if (footNotesPos) {
      let resolved = state.doc.resolve(footNotesPos)
      transaction.replaceRangeWith(footNotesPos, resolved.end(), newFooters)
    }
    else {
      transaction.insert(state.doc.content.size, newFooters)
    }
  }
  else if (footNotesPos) {
    if(transaction == null) transaction = state.tr
    let resolved = state.doc.resolve(footNotesPos)
    transaction.delete(footNotesPos, resolved.end())
  }

  return transaction
}

export function createFootNotesPlugin() {
  return new Plugin({
    key: 'footnotes',
    appendTransaction: (transactions, oldState, newState) => {
      // bail if no change
      if (transactions.find(tr => tr.docChanged )){
        return findTheFoot(newState)
      }
    }
  })
}

I think this might be a case where it actually makes sense to have different editors for different parts of the content—you can (using the same techniques that are used in the footnote example) create a mini-editor that directly edits some node in a bigger document (which may also have an editor associated with it). Locating the footnotes in a document, even if it’s big, shouldn’t be very expensive. You could maintain a list of mini editors for the footnotes below the main editor (making sure you don’t completely redraw them every time, but as much as possible sync the exiting editors with the updated content on updates).

I have something along those lines in a custom nodeview:

open(event) {
  event.preventDefault()

  ReactDOM.render(
    <OverrideModal
      title={`Edit Footnote ${this.node.attrs.number}`}
      submitButtonText='Save'
      onSubmit={this._modalSubmitCallback.bind(this)}
      element={this._standInNode()}
      show
    />,
    this._modalAnchor()
  )
}

and then…

_modalSubmitCallback(data: any) {
  const position = this.getPos()
  const frag = Fragment.fromJSON(this.view.state.schema, data.footnoteContent.content)
  const newNode = this.node.copy(frag)
  this.view.dispatch(this.view.state.tr.replaceRangeWith(position, (position + this.node.nodeSize), newNode))
}

My footnote list element gets hidden with a display: none css property.

edit: The stand-in node works around some quirks in the prompt code we’re using. (As an aside I’m actually hoping to move away from using React since I have no overarching application to interact with and integrating it with PM seems more trouble than it’s worth. Might even bring back crel or something similar.)

// The editing UI provided by our custom OverrideComponent assumes
// it's dealing with attributes not inner contents so we fake it out here
_standInNode() {
  return {
    attrs: {
      footnoteContent: JSON.stringify({
        type: 'doc',
        content: this.node.content
      }),
      number: 50
    },
    type: this.node.type
  }
}