How to control gapcursor

Is there a recommended way of controlling where the gapcursor goes? I have a set document structure like this:

doc(article(title(...),...,body(...)))

If there is only a table in the body and I move the cursor with the arrow keys to the right from the last table cell, the gap caret blinks horizontally and is being put after the article node:

doc(article(title(...),...,body(table(...)))|)

Rather than, as I would expect, after the table:

doc(article(title(...),...,body(table(...)|)))

If the user inputs any keys with the gap cursor there, the input goes into the last table cell rather than starting a new paragraph after the table. If I hit “Enter”, I would expect for a new, empty paragraph to be formed after the table, but nothing happens.

It’ll be put at the outermost (least deep) position. Having multiple gap cursor positions that are visually identical would be confusing, so this is how it determines a canonical position.

That’s very odd. Are the any errors in your dev console when this happens?

Ok, the thing is that if it weren’t for this gapcursor, there really is no other way for the user to put the caret that far out. The selection is normally always within the article node (likely with the exceptions mentioned in Changing doc.attrs? - #13 by Jordan). This is a minor issue, but with the text being added where it is, it’s not really useful for me right now.

No, and it does not seem that strange to me. The scheme does not allow anything except one article node in the document node, and it only allows a predetermined list of children in the article node. So it cannot add text in any of those. What I would like it to do is add a paragraph after the table in the body. But instead it decided to add extra text within the table.

That is, indeed, the idea of the gap cursor – allowing selections where it wasn’t possible before.

Well, let me assure you that it is strange, since the cursor position was outside of the table when you inserted the content. I’ve tried to imitate your schema to reproduce this, and what I got was that the first character I typed was swallowed because it couldn’t be inserted at the cursor position, after which the cursor was reset to the end of the table, causing further typing to end up in the table.

I’ve added a patch to the gap cursor repository that causes a gap cursor selection to search for a nearby position at which the content can be inserted when it can’t be inserted at the cursor position, and I think that will help in your situation.

Ok, good point. Let me rephrase that: The users are only supposed to enter content into one of the “sections” of the document - title, subtitle, abstract, keywords, authors, body. So in the toolbar in which of these five sections the selection currently is (or nothing if the selection crosses part boundaries). I need the gap cursor so that they can enter something beyond a table or figure within the body. But the gap cursor will effectively place the selection outside the body. The solution for our case is simply to treat this position as an exception and for the menu to display that the selection is in the body when it really is not. It shouldn’t matter as any content entered at that place would go into the body.

This is slightly different than what I found, but maybe one of our plugins somehow caused the input event of that first letter to be repeated once. That should then cause the behavior you mention.

Great! I will check it out.

Just saw your other patch today [1] and this seems to address our usecase. Great!

[1] Allow gap cursors in non-top nodes ¡ ProseMirror/prosemirror-gapcursor@89bb7d7 ¡ GitHub

I tried this out now. I must be doing something wrong still. I’ve added allowGapCursor: false on the two highest levels (doc and article), hoping the gapcursor would then appear in the third highest level, the body. But instead it does not appear at all beyond the table. Also, I realize I should put it on the table and table row, but given the way prosemirror-tables is structured, I don’t see a way for me to add that using the spec.nodes.append(tableNodes(...)) helper function.

I solved this part by doing

let tableNodeObj = tableNodes({
    tableGroup: "table_block",
    cellContent: "block+"
})

tableNodeObj.table.allowGapCursor = false
tableNodeObj.table_row.allowGapCursor = false

spec.nodes = spec.nodes.append(tableNodeObj)

So now the question why the gapcursor does not go outside the table inside the body. The body is defined like this (with or without allowGapCursor):

let body = {
    content: "(block | table_block)+",
    group: "part",
    marks: "annotation",
    defining: true,
    allowGapCursor: true,
    isMetadata() {
        return true
    },
    parseDOM: [{
        tag: "div.article-body"
    }],
    toDOM(node) {
        return ["div", {
            class: 'article-part article-body'
        }, 0]
    }
}

Because you disabled gap cursor on the top nodes, I expect. You don’t need to do that.

Removing the allowGapCurosr: false on doc and article makes the gapcursor go into the doc beyond the article (not inside the body):

doc(article(title(...),...,body(table()))|)

Pressing any key with the gapcursor there shows no reaction.

I’m out of ideas. You could try debugging GapCursor.replace to see which replacements it is trying and why those might be failing.

Ok, I’ve tried to modify the gapcursor code so that it would work for us. The two issues seem to be that:

  • When placing the caret, it always prefers the top level (depth === 0) node, and there is not a way to make it go any lower than that. I have simply made it look for the top most node that does not set allowGapCursor = false.

  • When inserting at the place of the gapcursor, and that content is inline content and the place the gapcursor is at is not a textblock node, it gives up. Instead, it would be good if it could wrap that inline content in a textblock node, such as a paragraph.

And that should be okay, since it’ll search for a valid insert position when you actually insert something.

The replace algorithm will automatically wrap such content so that it fits, if you’ve structured your schema so that paragraphs are the first block-like node, this will just work as it is.

OK, but at least in our case, it always went straight for the top doc node, rather than go right after the table but still within the body node, which is where we needed it to be. From looking at the code, it seems like this was because there was no node beyond the table node inside the body node.

Ok, maybe something is wrong with our scheme, but that didn’t work in our case. It was trying to insert the textnode directly in a block node that wasn’t a textblock node, and that gave it a replacestep with a content.size of 0, which is why it gave upo on that. There are likely better ways to invoke the wrapping than what I have done in the patch though.

Ok, I was not aware of this bit. And that explains how it’s finding the relevant textblock node. Let me try that.

I have now changed the order of the nodes in the spec, so it starts with the paragraph:

let spec = {
    nodes: from({
        paragraph,
        ...
   }),
   ...
}

But unfortunately, that didn’t seem to work. When I turned my wrapping code off, it just stopped inserting anything, like before.

Btw - While writing my patch, I made some basic errors the first few times with undefined variables in the replace-function, which ended up throwing JavaScript errors. The odd thing about this was that when those errors were thrown, the new paragraph with content in it was actually inserted in the correct place (the code ensuring correct gapcursor placement had been added earlier). I figure if that function simply fails, it falls back to some generic insertion function that then can insert content correctly.

The idea is that this is okay, since it will search for a valid insertion point when you insert something.

Your best bet at this point is to try and find the minimal schema that illustrates the problem, and submit a test file with that, so that I can see what’s going on.

Yes, that’s how I understood you earlier. But at least for us, that’s sub-optimal, as the UI shows something different if the selection is in the top node or a node below it. I imagine others will draw borders and padding/margin around some of their sub elements, and then it will be even more obvious that the caret is in the wrong position.

But I understand that this is not the case for the use cases you are looking at, and this is kind of independent from the insertion issue, so whatever solution we come up with for the insertion should work for both approaches.

At this stage I have code that is working for our usecase, but it would be best if we could get this solved in a more general way and with your insights in how everything works, so that this can also applied to other usecases (and I hopefully don’t have to maintain a fork just for our usecase).

Well, I did what you first asked me to do and found the problem. It was trying to insert an inline textnode into a blocknode that could not directly hold textnodes, but needed to wrap these in a textblocknode first.

Trying to use replaceStep and inserting the textnode into the body node created a step with a content.size of 0 in https://github.com/ProseMirror/prosemirror-gapcursor/blob/master/src/gapcursor.js#L28 . That’s why it didn’t insert it. It did try out three different positions, but in none of those could it just directly insert it. After I changed it to wrap any inline node into a paragraph prosemirror-gapcursor/src/gapcursor.js at master · johanneswilm/prosemirror-gapcursor · GitHub, it does work and inserts the character in the right place.

I suspect there is something about replaceStep that isn’t quite working right, or maybe it’s not being called entirely correctly or some such thing.

That might indeed be an issue. But I am not happy with your solution, since it doesn’t address issues like what happens when there are nodes on both sides around a position in a node that disallows gap cursors — two gap cursor positions? only one? which side? — and generally feels a bit ad-hoc.

The intended behavior is that replaceStep will, on not finding a place to directly insert the content, find a way to wrap it (via line 282 in prosemirror-transform/src/replace.js), and automatically do this. I haven’t been able to figure out why this is failing in your case (in my attempt to reproduce the issue it worked). Maybe you could run a similar call (bodyNode.contentMatchAt(bodyNode.childCount).findWrapping(schema.nodes.text)) and see if it returns null and if so, why.