Tiptap: Always having an empty paragraph at the end of the document

Created on 21 Dec 2018  路  15Comments  路  Source: ueberdosis/tiptap

In some scenarios it is almost impossible to put a cursor after the last element, like for the "Embed" example: https://tiptap.scrumpy.io/embeds

It would be great to always have an empty trailing paragraph to avoid this problem.

feature request

Most helpful comment

I've added a TrailingNode() extension, which you can see at the embed example. For me, your way of calculating the last node with findNodeAtPosition(Selection.atEnd(tr.doc).$from) didn't work at the embed example so I think I found a better solution.

These are the default options:

new Editor({
  extensions: [
    new TrailingNode({
      node: 'paragraph',
      notAfter: ['paragraph'],
    }),
  ],
})

All 15 comments

Yeah that sounds good.

@philippkuehn do you think that an approach like the one used for https://tiptap.scrumpy.io/title would be a smart way to solve this issue?

Hmm, I don't think so. But I still don't know how to solve this problem 馃槄

Thanks for the feedback. I tried to implement it this way, but indeed I didn't get it working correctly. The problem was that this empty last element would not automatically change to a regular paragraph element when typing in it.

I asked on the ProseMirror list, maybe they have an idea: https://discuss.prosemirror.net/t/empty-trailing-block/2072

@holtwick if you find any solutions I'd love to hear it. The use case for our project is embedding textinputs into the notes so we can update values in a 3rd party application. Like in the embed Scrumpy example, it gets stuck immediately thereafter. Workarounds or fixes, I'm all ears!

As mentioned in the linked Prosemirror thread that @holtwick opened, gapcursor does kind of work (you just need to set showGapCursor: true on the node), but it's far from a complete solution. You can change the CSS for the cursor, but I was having trouble making it appear consistently. Now it only really appears when you are on the selection point after the node, rather than on the node itself; this would be a fine solution if there was an intuitive way to present this to the user, but I haven't figured that part out.

Really what I settled on is this:

// Catch events on stopEvent to ensure line breaks work as expected
// This works well for text fields
stopEvent(e) {
    if (e.inputType) {
      if (e.inputType === 'insertLineBreak') {
        return false;
      }
    } else if (e.key) {
      if (['Enter', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
        return false;
      }
    }
    return true;
}
// Enable gapCursor and set selectable to true
get schema() {
    return {
      attrs: {  ... },
      group: 'block',
      selectable: true,
      showGapCursor: true,
      atom: true, // not quite sure why I needed this, but it seemed to help
     ...
}

Finally, in my component, I set the cursor position to the NodeView when the <input> was focused. This will obviously differ depending on your use case.

fieldFocused() {
  this.view.dispatch(this.view.state.tr
   .setSelection(NodeSelection
     .create(this.view.state.doc, this.getPos())));
},

Again, far from a perfect solution, but hopefully this will help others looking into similar use cases. I had some jankier solutions for ensuring there was always a trailing paragraph (I threw those out), but we won't talk about those 馃ぃ

@ryanbliss Thanks for the input. I believe having the trailing empty paragraph is very important, because GapCursor only works in mouse based areas. On mobile devices it does not help that much. The overall experience is more natural having the last empty line.

Many many thanks to @ifiokjr !!! I adopted his remirror implementation for TipTap. The following is a plugin definition to do the job. I have put it into my Paragraph extension, but it could be anywhere else. Hope this helps:

get plugins() {

    const findNodeAtPosition = ($pos) => {
      const { depth } = $pos
      const node = $pos.node(depth)
      return {
        pos: depth > 0 ? $pos.before(depth) : 0,
        start: $pos.start(depth),
        node,
      }
    }

    const nodeEqualsType = ({ types, node }) => {
      return (Array.isArray(types) && types.includes(node.type)) || node.type === types
    }

    let plugin = new PluginKey('paragraph-trailing')

    return [
      new Plugin({
        key: plugin,
        view() {
          return {
            update: view => {
              let state = view.state
              const insertParagraphAtEnd = plugin.getState(state)

              if (!insertParagraphAtEnd /* || !ensureTrailingParagraph */) return
              const { pos, node } = insertParagraphAtEnd

              let type = state.schema.nodes.paragraph
              view.dispatch(view.state.tr.insert(pos + node.nodeSize, type.create()))
            },
          }
        },
        state: {
          init: (_, state) => {
            let type = state.schema.nodes.paragraph
            let result = findNodeAtPosition(Selection.atEnd(state.tr.doc).$from)
            return nodeEqualsType({ node: result.node, types: type }) ? false : result
          },
          apply: (tr, oldState, state) => {
            if (!tr.docChanged) {
              return state || oldState
            }
            let type = state.schema.nodes.paragraph
            let result = findNodeAtPosition(Selection.atEnd(tr.doc).$from)
            return nodeEqualsType({ node: result.node, types: type }) ? false : result
          },
        },
      }),
    ]
  }

Thanks @holtwick. Will try it. It's definitely something I want to see in the core.

I enhanced the code to also accept headers as trailing lines, which will avoid problems with common placeholder plugin configurations:

 let types = [
    state.schema.nodes.paragraph,
    state.schema.nodes.header]
// ...
return nodeEqualsType({ node: result.node, types }) ? false : result

I've added a TrailingNode() extension, which you can see at the embed example. For me, your way of calculating the last node with findNodeAtPosition(Selection.atEnd(tr.doc).$from) didn't work at the embed example so I think I found a better solution.

These are the default options:

new Editor({
  extensions: [
    new TrailingNode({
      node: 'paragraph',
      notAfter: ['paragraph'],
    }),
  ],
})

@philippkuehn your solution is so much better 馃憤

I didn't even know that doc.lastChild existed which makes the API so much better. Also separating it into its own extension makes a lot more sense. I hope you don't mind me "borrowing" aspects for remirror.

@ifiokjr That's one of the great parts of open source 鉁岋笍

@philippkuehn and @holtwick this is incredible thank you!

TrailingNode mess up the history sometime. May be someway can close history temporary? @philippkuehn

Undo is not available, when content is selected from the trailing paragraph & deleted...

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ageeye-cn picture ageeye-cn  路  3Comments

bernhardh picture bernhardh  路  3Comments

klaasgeldof picture klaasgeldof  路  3Comments

nekooee picture nekooee  路  3Comments

connecteev picture connecteev  路  3Comments