Issues with NodeView Cursor Behavior in ProseMirror

I’ve been working on integrating a NodeView into my ProseMirror-based document editor and encountered several cursor behavior issues that I’m struggling to understand. I’m hoping to find some solutions or insights.

My code is as follows:

import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Schema, DOMParser } from 'prosemirror-model';
import { schema as basicSchema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import { exampleSetup } from 'prosemirror-example-setup';


const editableNode = {
    inline: true,
    content: "inline*",
    group: "inline",
    selectable: true,
    parseDOM: [{
        tag: 'span[data-type="editable-node"]',
        getAttrs: dom => ({
            "data-placeholder": dom.getAttribute("data-placeholder") || ""
        })
    }],
    toDOM(node) {
        return ["span", {"data-type": "editable-node", "data-placeholder": node.attrs["data-placeholder"] || ""}, 0];
    }
};

const mySchema = new Schema({
    nodes: addListNodes(basicSchema.spec.nodes, 'paragraph block*', 'block').append({
        editableNode,
    }),
    marks: basicSchema.spec.marks,
});

class EditableNodeView {
    constructor(node, view, getPos) {
        this.node = node
        this.dom = this.createContainer();
        this.contentDOM = this.createContentArea(node);

        this.dom.appendChild(this.contentDOM);

    }

    createContainer() {
        const dom = document.createElement("span");
        dom.setAttribute("data-type", "editable-node");
        dom.style.border = "1px solid gray";
        dom.style.marginLeft = "2px";
        dom.style.marginRight = "2px";
        dom.style.borderRadius = "5px";
        dom.style.padding = "1px 10px";
        return dom;
    }

    createContentArea(node) {
        const contentDOM = document.createElement("span");
        contentDOM.contentEditable = true;
        contentDOM.setAttribute("data-placeholder","this is placeholder")
        contentDOM.innerText = "Every morning, we have the opportunity to embrace a new beginning. The sun rises, casting a golden glow over the world, reminding us that each day holds the promise of fresh starts and new adventures."
        return contentDOM;
    }
}


window.view = new EditorView(document.querySelector('#editor'), {
    state: EditorState.create({
        doc: DOMParser.fromSchema(mySchema).parse(document.querySelector('#content')),
        plugins: exampleSetup({ schema: mySchema }),
    }),
    nodeViews: {
        editableNode(node, view, getPos) {
            return new EditableNodeView(node, view, getPos);
        },
    }
});

function insertEditableNode(nodeType) {
    return (state, dispatch) => {
        const { selection } = state;
        const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos;

        const node = nodeType.create();
        const transaction = state.tr.insert(position, node);

        if (dispatch) {
            dispatch(transaction);
        }

        return true;
    };
}

const insertCommand = insertEditableNode(mySchema.nodes.editableNode);

document.querySelector('#myButton').addEventListener('click', () => {
    const { state, dispatch } = window.view;
    insertCommand(state, dispatch);
});
<!DOCTYPE html>
<html>
<head>
  <title>ProseMirror Editor</title>
  <script src="dist/prosemirror-bundle.min.js" defer></script>
  <link rel="stylesheet" href="dist/editor.css">
</head>
<style>
  /* CSS 规则 */
  [data-type="editable-node"] .empty::before {
    content: attr(data-placeholder); /* 使用 data-placeholder 属性作为内容 */
    color: #ccc;
    font-style: italic;
    pointer-events: none;
  }

  p {
    line-height: 2;
  }

</style>
<body>
<!--<div id="mad_interactive_editor"></div>-->
<button id="myButton">Insert Input Node</button>
<div id="editor"></div>
<div id="content" style="display: none;">
  <p>Hello ProseMirror</p>
  <p>This is editable text. You can focus it and start typing.</p>
</div>
</body>
</html>

Here is a summary of the behavior I observed:

  1. Cursor Jumping to End of NodeView:
  • Action: I inserted a NodeView between the letters ‘P’ and ‘r’ in the document text “Hello ProseMirror,” resulting in “Hello P[nodeview]roseMirror.” I set contentDOM.contentEditable = "true", expecting the NodeView’s contentDOM to be editable.
  • Issue: When I click at the very start of the NodeView, expecting the cursor to be placed to the left of the word “Every,” the cursor instead jumps to the end of the NodeView, right of the word “adventures.” 20231207160434_rec_
  1. Cursor Immobile When Moved Left with Keyboard:
  • Issue: If I try to move the cursor left using the keyboard, it doesn’t move. 20231207160434_rec_
  1. Cursor Movement After Typing:
  • Action: After typing some content to the right of “adventures.” and then trying to move the cursor left, it then moves as expected. 20231207160548_rec_
  1. Cursor Positioning on Right-End of NodeView:
  • Issue: When the cursor is at the right end of the NodeView and I attempt to move it right with the keyboard, I expect it to be between the NodeView and “roseMirror.” However, it ends up between ‘r’ and ‘oseMirror.’ Moving the cursor left then correctly places it between the NodeView and “roseMirror.” Further left movement doesn’t go to the expected end of the NodeView but to the left of the last letter in the NodeView. 20231207160635_rec_
  1. Cursor Positioning on Left-End of NodeView:
  • Issue: When the cursor is at the left end of the NodeView and I attempt to move it left with the keyboard, I expect it to be between the NodeView and “P.” However, it ends up between 'Hello ’ and ‘P’. Moving the cursor right then correctly places it between the NodeView and “P.” Further right movement doesn’t go to the expected start of the NodeView but to the right of the first letter in the NodeView. 20231207160723_rec_

I observed these issues in both Chrome and Firefox browsers. The version of ProseMirror I am using is:

 "devDependencies": {
    "@rollup/plugin-commonjs": "^25.0.7",
    "@rollup/plugin-node-resolve": "^15.2.3",
    "@rollup/plugin-replace": "^5.0.5",
    "@rollup/plugin-terser": "^0.4.4",
    "prosemirror-model": "^1.19.3",
    "prosemirror-schema-basic": "^1.2.2",
    "prosemirror-state": "^1.4.3",
    "prosemirror-transform": "^1.8.0",
    "prosemirror-view": "^1.32.5"
  }

Could you please provide any insights or guidance on why these issues are occurring and how they might be resolved?

Thank you for your assistance.

I apologize for directly using innerHTML to initialize the content, as this approach is incorrect. This method fails to properly render the node content through the schema.With these changes, the issues of “Cursor Jumping to End of NodeView” and “Cursor Immobile When Moved Left with Keyboard” no longer exist.

I have made the following adjustments to the code:

import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import {Schema, DOMParser, DOMSerializer} from 'prosemirror-model';
import { schema as basicSchema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import { exampleSetup } from 'prosemirror-example-setup';


const editableNode = {
    inline: true,
    content: "inline*",
    group: "inline",
    selectable: true,
    parseDOM: [{
        tag: 'span[data-type="editable-node"]',
        getAttrs: dom => ({
            "data-placeholder": dom.getAttribute("data-placeholder") || ""
        })
    }],
    toDOM(node) {
        return ["span", {"data-type": "editable-node", "data-placeholder": node.attrs["data-placeholder"] || ""}, 0];
    }
};

const mySchema = new Schema({
    nodes: addListNodes(basicSchema.spec.nodes, 'paragraph block*', 'block').append({
        editableNode,
    }),
    marks: basicSchema.spec.marks,
});

class EditableNodeView {
    constructor(node, view, getPos) {
        this.node = node
        this.dom = this.createContainer();
        this.contentDOM = this.createContentArea(node);
        this.dom.appendChild(this.contentDOM);
        this.updateContent(node);
    }

    createContainer() {
        const dom = document.createElement("span");
        dom.setAttribute("data-type", "editable-node");
        dom.style.border = "1px solid gray";
        dom.style.marginLeft = "2px";
        dom.style.marginRight = "2px";
        dom.style.borderRadius = "5px";
        dom.style.padding = "1px 10px";
        return dom;
    }

    createContentArea(node) {
        const contentDOM = document.createElement("span");
        contentDOM.contentEditable = true;
        contentDOM.setAttribute("data-placeholder","this is placeholder")
        // contentDOM.innerText = "Every morning, we have the opportunity to embrace a new beginning. The sun rises, casting a golden glow over the world, reminding us that each day holds the promise of fresh starts and new adventures."
        return contentDOM;
    }

    updateContent(node) {
        this.contentDOM.textContent = '';
        const fragment = DOMSerializer.fromSchema(mySchema).serializeFragment(node.content);
        this.contentDOM.appendChild(fragment);
    }
}


window.view = new EditorView(document.querySelector('#editor'), {
    state: EditorState.create({
        doc: DOMParser.fromSchema(mySchema).parse(document.querySelector('#content')),
        plugins: exampleSetup({ schema: mySchema }),
    }),
    nodeViews: {
        editableNode(node, view, getPos) {
            return new EditableNodeView(node, view, getPos);
        },
    }
});

function insertEditableNode(nodeType) {
    return (state, dispatch) => {
        const { selection } = state;
        const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos;

        const initialContent = mySchema.text("Every morning, we have the opportunity to embrace a new beginning. The sun rises, casting a golden glow over the world, reminding us that each day holds the promise of fresh starts and new adventures.");
        const node = nodeType.create(null, initialContent);

        const transaction = state.tr.insert(position, node);

        if (dispatch) {
            dispatch(transaction);
        }

        return true;
    };
}

const insertCommand = insertEditableNode(mySchema.nodes.editableNode);

document.querySelector('#myButton').addEventListener('click', () => {
    const { state, dispatch } = window.view;
    insertCommand(state, dispatch);
    console.log("test",window.view.state.doc.content.toJSON())
});

However, issues still exist with the cursor at the boundaries of the nodeview, both entering and exiting it. The behavior is as follows:

Cursor Movement at NodeView’s Front Edge:

When the cursor is at the very front inside the edge of the nodeview, if it moves left out of the nodeview, it doesn’t land at the boundary between the nodeview and the text content. Instead, it skips one character to the left of the text content. This behavior is consistent in both Safari and Firefox. 20231207202139_rec_

Cursor Movement at NodeView’s Rear Edge:

In Firefox

When the cursor is at the very end inside the edge of the nodeview, moving right out of the nodeview results in the cursor not landing at the boundary between the nodeview and the text content but jumping one character to the right of the text content. 20231207202225_rec_

In Safari

The cursor never properly lands at the very right end inside the nodeview, but at the left of the nodeview’s last character. Moving right out of the nodeview, the cursor lands normally at the boundary between the nodeview and the text content. 20231207202259_rec_

Cursor Position Outside NodeView’s Left Edge:

In Firefox

The cursor can land correctly at the boundary between the nodeview and the text content. Moving the cursor right, it does not land at the very left of the nodeview but to the right of the nodeview’s first character. 20231207202401_rec_

In Safari

The cursor does not land correctly at the boundary between the nodeview and the text content, but to the left of the first character adjoining the nodeview’s left side. Moving the cursor right, it moves normally to the very left of the nodeview. 20231207202432_rec_

Cursor Position Outside NodeView’s Right Edge:

In both Safari and Firefox: The cursor can land at the boundary between the nodeview and the text content. Moving the cursor left, it does not move to the very right of the nodeview but to the left of the nodeview’s last character. 20231207202508_rec_

I’m not going to debug all that code, but inline nodes with content are generally problematic. The browser won’t treat the node boundary as a separate cursor position, so you won’t be able to control whether the cursor is inside or outside the node.

1 Like

Okay, thank you for your answer~