The weird backspacing functionality with inline nodes

So as said, inline nodes work weirdly with many different keypress commands. Well, this is expected and Martin has mentioned that any custom inline functionality will require a lot of manual plumbing to make it work as intended.

Anyway, recently I found a very peculiar behavior which happens when backspacing inline nodes with text content. After inputting something in an inline node, then breaking out of it into regular text (all inside single paragraph), pressing backspace would cause all content in the paragraph before it to be deleted. As a matter of fact this would happen even if new text inside inline node would be inserted, the second character would prevent it. After that any backspacing would work as intended, only deleting a single character, moving in and out of inline nodes as it’s supposed to. But basically this behavior is like pressing Cmd+Backspace in macOS, all content before the cursor is deleted to the start of the paragraph.

I tried to track down this misbehaving piece of code, but I think it would require me to add breakpoints to the prosemirror-view source code which I guess is doable but would take time. Easy way to fix this, which I guess is the only way, is to just reimplement the Backspace and be done with it, but I wanted to know if there was a good reason for this behavior? It’s just so odd.

Also similarly, I copied this as a test for my custom backspace behavior as in the prosemirror-view state.tr.delete($cursor!.pos - 1, $cursor!.pos).scrollIntoView()

This has a sort of same problem, where as this time backspacing with cursor next to inline node would take one extra backspace to start deleting the content inside the inline node. Sometimes it would not delete anything at all unless the editor is specifically refocused.

Okey well now that I took a good look at the transactions happening, I guess the reason for the duplicate backspace is because the selection in every backspace is moved only by one, leaving it outside the inline node the first time, next time properly moving it inside its text content. What makes it weirder is that refocusing the editor to the same place will cause it to work properly.

Hopefully this wasn’t too confusing without example site, I can deploy it if it’s worth something. And I haven’t forgotten about the mark problem, which I mentioned last time posting here, but since it’s waay down in the todo list haven’t gotten around thinking about it more =). Cheers, and thank you for your time.

This is definitely not by design, but I can’t reproduce it, so yes, it would be helpful if you could distill the issue down to the simplest script that demonstrates it.

All right, I deployed it here http://testi-bucketti.s3-website.eu-north-1.amazonaws.com/

There are a lot of other bugs there too. By the way is the best way of hijacking all text inserts by watching transactions for ReplaceSteps? Then use appendTransaction to reverse the insert and do something else instead?

Also there is the mark bug I previously mentioned, where I can’t seem to toggle them off for a text both marks have been applied to. Probably a silly error on my part, haven’t looked into it further.

So that appears to be doing a lot more than just setting up an editor with some inline node in it – I couldn’t figure out how to reproduce the issue you described there, but even if I could, it wouldn’t be much use, since I am not going to debug all that code.

You might be able to use handleTextInput to do this in a cleaner way.

I see. Well, all right. I did try to break it down further and I think inserting inline nodes with eg schema.nodes.word.create({ class: 'word' }, schema.text('asdf')) messes up something. In the previous website which I redeployed with less moving parts, I have two actions here:

export function createWord(state: EditorState, dispatch: (tr: Transaction) => void) {
  const { schema } = state
  const { $from, $to } = state.selection
  const tr = state.tr
    .insert($from.pos, schema.nodes.word.create({ class: 'word' }, schema.text('asdf')))
    .scrollIntoView()
  dispatch(tr)
  return true
}

export function breakWord(state: EditorState, dispatch: (tr: Transaction) => void) {
  const { schema } = state
  const { $from, $to } = state.selection
  // Break word only if no current selection
  if ($from.pos === $to.pos && $from.parent.type === schema.nodes.word) {
    const targetPos = $from.pos + 1
    const nodeAtPos = state.doc.resolve(targetPos)
    let tr = state.tr
    if (nodeAtPos.parent.type === schema.nodes.word) {
      tr = tr.split($from.pos)
    }
    tr = tr
      .insert(targetPos, schema.text(' '))
      .setSelection(TextSelection.create(state.doc, $from.pos + 2))
      .scrollIntoView()
    dispatch(tr)
    return true
  }
}

which are mapped accordingly:

  plugins.push(keymap({
    'Ctrl-w': createWord,
    'Space': breakWord,
  }))

and the schema is:

export const schema = {
  nodes: {
    doc: {
      content: 'block+'
    },
    paragraph: {
      group: 'block',
      content: 'inline*',
      attrs: {
        spellcheck: { default: 'false' },
      },
      parseDOM: [{ tag: 'p' }],
      toDOM(node) { return ['p', node.attrs, 0] }
    },
    word: {
      inline: true,
      group: 'inline',
      content: 'inline*',
      attrs: {
        class: { default: 'word', },
        spellcheck: { default: 'false' },
      },
      parseDOM: [{ tag: 'span' }],
      toDOM(node) { return ['span', node.attrs, 0] }
    },
    text: {
      group: 'inline'
    },
  },
} as SchemaSpec

So after each createWord action call, the selection’s position ($from & $to) seem to reference to the past state’s selection. So instead of the newly created word-node, the parent of $from is here a paragraph:

if ($from.pos === $to.pos && $from.parent.type === schema.nodes.word) {

I guess maybe because of that, a backspace will erase everything before the position inside the paragraph. Yet if I try to set the selection by-hand after the insertion of the word-node, it just says Position out of range (or it sets the position wrong).

So is there some trick in creating inline-nodes, or what is going on here?

And thanks, handleTextInput seemed to work nicely.

EDIT: and funny thing, that locally pressing two consecutive spaces after creating a word (with ctrl+w) will not do anything yet in the deployed version you have to keep small pauses to avoid period + space insertion. Strange.

This is the problem—the selection is using state.doc, but the transaction is already at a changed document, so the two don’t match up.

You’ll have to do something like:

tr = tr.insert(targetPos, schema.text(' '))
tr.setSelection(TextSelection.create(tr.doc, $from.pos + 2))

(Note tr.doc rather than state.doc in the second line.)

The library should probably check for this, since it comes up. I’ve released prosemirror-state 1.2.4 with that check.

Ahh, makes sense. That seemed to remedy the problem. Quite painful bug since it’s so non-obvious, so a check would be indeed helpful!

Regarding the problems with inline nodes, another difficult one which I fixed with a rude hack, is how to break the paragraph inside inline-node when enter is inserted. Currently the way I’m doing it is:

export function handleEnterInsideWord(state: EditorState, dispatch: (tr: Transaction) => void) {
  const { schema } = state
  const { $from, $to } = state.selection
  // No selection & the cursor inside word-node
  if ($from.pos === $to.pos && $from.parent.type === schema.nodes.word) {
    const blockParent = $from.node(1)
    const newBlock = blockParent.type.create(blockParent.attrs)
    // Very hacky way of splitting the blockNode from inside the word-node to a new blockNode
    // Basically we just insert a new blockNode and then immediately delete, leaving out the content split in the process
    // What is nice about this is also the selection remaining at the cut position
    const tr = state.tr
      .insert($from.pos, newBlock)
      .delete($from.pos + 1, $from.pos + 3)
      .setMeta('plugin', 'wordActions handleEnterInsideWord')
    dispatch(tr)
    return true
  }
}

Since I couldn’t figure out the right way. Now that I’m a bit more experienced with PM after writing that, I’d guess what I should have done is: cut the remainder of the paragraph from the current cursor position, then insert a new paragraph/block node with the cut slice as its content?

Also, what is a sure way of capturing all deletion events/transactions? As far as I know, there isn’t a handler for that so one would have to resort to either mapping all deletion key commands to custom commands, or watching transactions for ReplaceSteps where the from and to positions are not equal? Or well, now that I think about it, I’d also have to watch for if text was inserted while there was a selection active which makes this problem a bit more difficult. But I guess handleTextInput will suffice for that.

It should be possible to create a slice that only has closing and opening tokens for your inline and block nodes, and then replace the position at the cursor with that. That’s a little cleaner in that it makes the change in a single step.

// node like the block parent with an empty inline node in it
let block = blockParent.copy(new Fragment($from.parent.create())
// Slice that opens both nodes at both sides, so that only the inner close-and-then-open
// tokens are part of it
let slice = new Slice(new Fragment([block]), 2, 2)
let tr = state.tr.replace($from.pos, $from.pos, slice).setMeta(...)

You can do some things with filterTransaction and appendTransaction. If you need more control, you’ll have to directly hook into the editor’s dispatchTransaction callback.