Understanding Cursor Position Behavior in Custom NodeView with Undo/Redo in ProseMirror

I am currently working with ProseMirror and have implemented a custom NodeView (InputNodeView) for an inline node (input_node) that facilitates text input. While basic text input operations are functioning as intended, I am observing unusual cursor behavior during undo and redo actions.

In detail, after sequentially typing characters like ‘A’, ‘B’, and ‘C’ within this NodeView and then performing an undo operation, the cursor shifts unexpectedly to the left of the first character (‘A’) instead of settling after the second character (‘B’). This pattern persists during subsequent undo and redo operations, deviating from my expectations. 20231208201216_rec_

I am utilizing the prosemirror-history plugin for managing undo and redo functionalities. My initial assumption was that cursor positions would be automatically and accurately managed by the plugin, based on historical input actions.

I wonder if there might be a specific aspect of my InputNodeView implementation influencing this behavior. Perhaps there is a particular way to align the NodeView with the history plugin’s functionality that I am not yet aware of?

I would be grateful for any insights or advice on how to align the NodeView with the undo/redo history in ProseMirror to maintain the expected cursor positions. Your assistance is greatly appreciated.

For reference, here is the implementation of my code:

import {EditorState, TextSelection} 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 input_node = {
    attrs: {
        "data-type":{default:"input"},
        "data-placeholder":{}
    },
    inline: true,
    content: "inline*",
    group: "inline",
    selectable: true,
    parseDOM: [{
        tag: 'span[data-type="input"]',
        getAttrs: dom => ({
            "data-type":dom.getAttribute("data-type") || "",
            "data-placeholder": dom.getAttribute("data-placeholder") || ""
        })
    }],
    toDOM(node) {
        return ["span", {"data-type": node.attrs["data-type"] || "", "data-placeholder": node.attrs["data-placeholder"] || ""}, 0];
    }
};

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

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

    stopEvent(event) {
        if ((event.key === 'Backspace' || event.key === 'Delete') && this.isEmpty()) {
            event.preventDefault();
            return true;
        }
        return false;
    }

    update(node) {
        if (!node.sameMarkup(this.node)) {
            return false;
        }

        this.node = node;

        return true;
    }

    destroy() {
    }

    isEmpty() {
        return this.contentDOM.textContent.trim() === '';
    }

    createContainer() {
        const dom = document.createElement("span");
        dom.contentEditable = false
        dom.setAttribute("data-type", this.node.attrs["data-type"] || "");
        return dom;
    }

    createContentArea(node) {
        const contentDOM = document.createElement("span");
        contentDOM.contentEditable = true;
        contentDOM.classList.add("ai-input-area")
        return contentDOM;
    }

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

    updateContentEmptyState() {
        if (this.contentDOM.textContent.trim() === "") {
            this.contentDOM.setAttribute("data-placeholder",this.node.attrs["data-placeholder"]|| "please input something")
        } else {
        }
    }
}

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

function insertinput_node() {
    return (state, dispatch) => {
        const { selection } = state;
        let position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos;
        const view  = window.view
        const jsonData = [
            {
                type: "text",
                text: "Now you are playing the role of a conference administration expert, help me write an invitation letter to a ",
            },
            {
                type: "input_node",
                attrs: {
                    "data-type": "input",
                    "data-placeholder": "VIP client",
                },
            },
            {
                type: "text",
                text: ".",
            },
        ];

        const nodes = jsonData.map((node) => {
            if (node.type === 'text') {
                return state.schema.text(node.text);
            } else {
                return view.state.schema.nodeFromJSON(node);
            }
        });

        const tr = view.state.tr;
        nodes.forEach((node) => {
            tr.insert(position, node);
            position += node.nodeSize;
        });

        view.dispatch(tr);
        view.focus();
    };
}

const insertCommand = insertinput_node();

document.querySelector('#myButton').addEventListener('click', () => {
    const { state, dispatch } = window.view;
    insertCommand(state, dispatch);
});
<!DOCTYPE html>
<html>
<head>
  <title>ProseMirror Editor</title>
  <meta charset="UTF-8">

  <script src="dist/prosemirror-bundle.min.js" defer></script>
  <link rel="stylesheet" href="dist/editor.css">
</head>
<style>
  .ai-input-area:empty::before {
    content: attr(data-placeholder);
    color: #ccc;
    font-style: italic;
    pointer-events: none;
  }

  .ai-input-area {
    border: 1px solid gray;
    border-radius: 5px;
    padding: 0px 10px;
    margin: 0 2px;
    display: inline-block;
    word-break: break-all;
    overflow-wrap: break-word;
  }
  .ai-input-area:focus {
    border: blue 1px solid;
  }

  p {
    line-height: 2;
  }
  .ProseMirror :focus-visible {
    outline: none;
  }
</style>
<body>
<button id="myButton">Insert Input Node</button>

<div id="editor"></div>
<div id="content" style="display: none;">
</div>
</body>
</html>
1 Like

Don’t create ‘contenteditable islands’ (where a node isn’t editable but then some child node is again). That will break selection, as you found.

1 Like

Thank you very much for your reply, Marijn.

The reason I set the contentEditable attribute to false on the outermost DOM of the nodeview is that I referred to some online rich text products (which I believe are not based on ProseMirror, even when contentEditable is set to false, the selection behavior within its contentDOM-like structure, as well as undo and redo functionalities, are all functioning normally.) that have contentEditable=false set on a layer similar to contentDOM. I found that setting it to false solved the issue of cursor misplacement when moving in and out of the contentDOM edges in Edge, Safari, Chrome, and Firefox. I didn’t expect this setting to cause ProseMirror’s selection functionality to fail.

The reason I need to implement an inline, input-like nodeview is because our scenario involves letting users selectively input and adjust content within a complete segment. This input-like nodeview needs to meet several requirements:

    1. normal cursor movement behavior (moving in and out of the nodeview);
    1. when the nodeview content is empty and the delete key is pressed, the nodeview should not be deleted;
    1. when the nodeview content is empty, a placeholder-like pseudo-class should appear;
    1. internal pasting and history recording functions of the nodeview should work normally.

To achieve this effect, I tried many techniques. Although I’m not sure if this is a long-term stable solution, at least in my development environment, the behavior is consistent in Edge, Chrome, and Safari. 20231209121613_rec_

However, there are still some minor issues in Firefox: when the cursor is at the very end of the nodeview and I press the right arrow key to move out of the nodeview, the cursor disappears. 20231209121740_rec_

Below is my code implementation:

import {EditorState,Plugin,PluginKey,TextSelection} 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 node_view = {
    content: "inline*",
    inline:true,
    group:"inline",
    selectable: true,
    parseDOM: [{
        tag: 'span.node-view',
    }],
    toDOM(node) {
        return ["span", { class: 'node-view' }, 0]; 
    }
};

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

class InputNodeView  {
    constructor(node, view, getPos) {
        this.node = node
        this.getPos = getPos
        this.view = view;

        this.dom = document.createElement('span')
        // this.dom.contentEditable = true
        this.dom.classList.add('node-view')

        this.contentDOM = document.createElement('span')
        this.contentDOM.contentEditable = true
        this.contentDOM.classList.add('content')

        /*
            Add zero-width spaces to both sides of the contentDOM.
            This approach, to some extent, unifies the behavior in Chrome, Edge, Firefox, and Safari
            when moving the cursor into the contentDOM from its edges, which otherwise causes misplaced cursor positions.
         */
        const zeroWidthSpaceLeft = document.createElement('span')
        zeroWidthSpaceLeft.innerHTML = '&#8203;';
        zeroWidthSpaceLeft.contentEditable = false

        this.dom.appendChild(zeroWidthSpaceLeft);
        this.dom.appendChild(this.contentDOM);

        const zeroWidthSpaceRight = document.createElement('span')
        zeroWidthSpaceRight.innerHTML = '&#8203;';
        zeroWidthSpaceRight.contentEditable = false

        this.dom.appendChild(zeroWidthSpaceRight);
    }
    // stopEvent(event) {
    //     console.log("event",event)
    //     if ((event.key === 'Backspace' || event.key === 'Delete') && this.isEmpty()) {
    //         event.preventDefault();
    //         return true;
    //     }
    //     return false;
    // }
}

export const reactPropsKey = new PluginKey("reactProps");

const keydownPlugin = new Plugin({
    key: reactPropsKey,
    props: {
        handleKeyDown(view, event) {
            if (event.key === "Backspace") {
                const { head } = view.state.selection;
                const node = view.state.doc.nodeAt(head);
                const prevNode = view.state.doc.nodeAt(head - 1);
                const prev2Node = view.state.doc.nodeAt(head - 2);

                if ((prevNode && prevNode.type.name === "node_view" || prev2Node && prev2Node.type.name === "node_view")) {
                    const dom = node ? view.nodeDOM(head) :prevNode? view.nodeDOM(head - 1): view.nodeDOM(head - 2);
                    console.log("dom",dom && dom.nodeType)
                    if (dom && dom.nodeType === Node.ELEMENT_NODE) {
                        const contentDOM = dom.querySelector('.content'); 
                        console.log("contentDOM",contentDOM)
                        if (contentDOM && contentDOM.textContent.trim() === '') {
                            /*
                                we encountered a cross-browser compatibility issue.
                                In Safari, we implemented a preventative measure to avoid
                                the inexplicable appearance of multiple node_views under certain circumstances.
                                However, in Firefox, when the last letter in a node_view is deleted,
                                the entire node_view unexpectedly disappears.
                                In contrast, Chrome and Edge browsers behave normally even without this preventative measure.
                                They can maintain the node_view properly even when a deletion operation is performed.
                             */
                            event.preventDefault();
                            return true; // Prevent the event from further propagation
                        }
                    } else if (dom && dom.nodeType === Node.TEXT_NODE) {
                        /*
                            Implement the logic to delete the last character,
                            to prevent the entire node_view from being lost in Firefox when the last letter is deleted.
                        */
                        let tr = view.state.tr;
                        if (tr.doc.content.size > 1) {
                            tr.delete(head - 1, head);
                            view.dispatch(tr);
                        }
                        return true
                    }
                }
            } else if (event.key === "ArrowLeft") {
                /*
                    Detect ArrowLeft and ArrowRight at the edge of the node_view, and use ProseMirror's transactions
                    to replace the browser's own cursor movement behavior. This is done to prevent the cursor from being
                    misplaced at the edges of elements with contentEditable=true
                 */
                const { head, $head } = view.state.selection;
                const parentNode = $head.parent;
                const parentNodeType = parentNode.type.name;
                if (parentNodeType === "node_view") {
                    const offsetInParent = $head.parentOffset;
                    console.log("offsetInParent",offsetInParent)
                    if (offsetInParent === 0) {
                        event.preventDefault();
                        const newPos = head - 1;
                        const newSelection = TextSelection.create(view.state.doc, newPos);
                        const tr = view.state.tr.setSelection(newSelection);
                        view.dispatch(tr);

                        return true;
                    }
                }
            } else if (event.key === "ArrowRight") {
                /*
                    Detect ArrowLeft and ArrowRight at the edge of the node_view, and use ProseMirror's transactions
                    to replace the browser's own cursor movement behavior. This is done to prevent the cursor from being
                    misplaced at the edges of elements with contentEditable=true
                 */
                const { head, $head } = view.state.selection;
                const parentNode = $head.parent;
                const parentNodeType = parentNode.type.name;
                if (parentNodeType === "node_view") {
                    const offsetInParent = $head.parentOffset;
                    console.log("parentNodeType",parentNodeType,"offsetInParent",offsetInParent,"parentNode.content.size",parentNode.content.size)
                    if (offsetInParent === parentNode.content.size) {
                        event.preventDefault();
                        const newPos = head + 1;
                        const newSelection = TextSelection.create(view.state.doc, newPos);
                        const tr = view.state.tr.setSelection(newSelection);
                        view.dispatch(tr);
                        return true;
                    }
                }
            }
            return false;
        }
    }
});

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

function insertnode_view() {
    return (state, dispatch) => {
        const { selection } = state;
        let position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos;
        const view  = window.view
        const jsonData = [{
            "type": "doc",
            "content": [
                {
                    "type": "paragraph",
                    "content": [
                        {
                            "type": "text",
                            "text": "This is still the text editor you’re used to, but enriched with node views."
                        },
                        {
                            "type": "node_view",
                            "content": [
                                {
                                    "type": "text",
                                    "text": "This is editable."
                                }
                            ]
                        },
                        {
                            "type": "text",
                            "text": "This is still the text editor you’re used to, but enriched with node views."
                        },
                    ]
                },
                {
                    "type": "paragraph",
                    "content": [
                        {
                            "type": "text",
                            "text": "Did you see that? That’s a JavaScript node view. We are really living in the future."
                        }
                    ]
                }
            ]
        }]

        const nodes = jsonData.map((node) =>
            view.state.schema.nodeFromJSON(node)
        );

        const tr = view.state.tr;
        nodes.forEach((node) => {
            tr.insert(0, node);
        });
        view.dispatch(tr);
        view.focus();
    };
}

const insertCommand = insertnode_view();

document.querySelector('#myButton').addEventListener('click', () => {
    const { state, dispatch } = window.view;
    insertCommand(state, dispatch);
});
function getPosition() {
    console.log("from",window.view.state.selection.from)
    console.log("to",window.view.state.selection.to)
    console.log("anchor",window.view.state.selection.anchor)
    console.log("$anchor",window.view.state.selection.$anchor)
    console.log(".state.doc.resolve(pos)",window.view.state.doc.resolve(window.view.state.selection.anchor))
    window.view.focus()
}

document.querySelector('#getpos').addEventListener('click', getPosition);

<!DOCTYPE html>
<html>
<head>
  <title>ProseMirror Editor</title>
  <meta charset="UTF-8">

  <script src="dist/prosemirror-bundle.min.js" defer></script>
  <link rel="stylesheet" href="dist/editor.css">
</head>
<style>
  .node-view {
    /*background: #FAF594;*/
    /*border: 3px solid #0D0D0D;*/
    border-radius: 0.5rem;
    margin: 1rem 0;
    position: relative;
    /*display: inline-block;*/
  }


  .label {
    margin-left: 1rem;
    background-color: #0D0D0D;
    font-size: 0.6rem;
    letter-spacing: 1px;
    font-weight: bold;
    text-transform: uppercase;
    color: #fff;
    position: absolute;
    top: 100px;
    padding: 0.25rem 0.75rem;
    border-radius: 0 0 0.5rem 0.5rem;
  }

  /*.content {*/
  /*  margin: 2.5rem 1rem 1rem;*/
  /*  padding: 0.5rem;*/
  /*  border: 2px dashed #0D0D0D20;*/
  /*  border-radius: 0.5rem;*/
  /*  display: inline-block;*/
  /*}*/

  .content:empty::before {
    /*content: attr(data-placeholder);*/
    content:"please input something";
    color: #ccc;
    font-style: italic;
    pointer-events: none;
  }

  .content {
    border: 1px solid gray;
    border-radius: 5px;
    padding: 0px 10px;
    margin: 0 2px;
    display: inline-block;
    word-break: break-all;
    overflow-wrap: break-word;
  }

  .content:focus {
    border: blue 1px solid;
  }

  p {
    line-height: 2;
  }
  .ProseMirror :focus-visible {
    outline: none;
  }
</style>
<body>
<button id="myButton">Insert Input Node</button>
<button id="getpos">Get Pos</button>

<div id="editor"></div>
<div id="content" style="display: none;">
</div>
</body>
</html>

Maybe this is a very silly suggestion, but for the Firefox-specific issue with the cursor disappearing, I would play with forcing a focus on the editor view dom element, and I would play with doing some of the selection and event management in a requestAnimationFrame callback.

I also had to implement something like this for my product, but I don’t remember the details of the issues in Firefox.

1 Like