Issue with Implementing ProseMirror Example: RangeError on Keyed Plugin

Hello,

I’ve encountered an issue that I’m struggling to resolve. I used the exact code from the official ProseMirror CodeMirror integration example found here: ProseMirror CodeMirror Example.

prosemirror-bundle.min.js:31657 Uncaught RangeError: Adding different instances of a keyed plugin (plugin$). 20240106182000_rec_

To give you a better understanding of my implementation, here are the relevant parts of my code and configurations:

  1. JavaScript Part (js):
// schema {
import {schema as baseSchema} from "prosemirror-schema-basic"
import {Schema} from "prosemirror-model"

let baseNodes = baseSchema.spec.nodes
const schema = new Schema({
    nodes: baseNodes.update("code_block", Object.assign(
        {}, baseNodes.get("code_block"), {isolating: true})),
    marks: baseSchema.spec.marks
})
// }
// nodeview_start{
import {
    EditorView as CodeMirror, keymap as cmKeymap, drawSelection
} from "@codemirror/view"
import {javascript} from "@codemirror/lang-javascript"
import {defaultKeymap} from "@codemirror/commands"
import {syntaxHighlighting, defaultHighlightStyle} from "@codemirror/language"

import {exitCode} from "prosemirror-commands"
import {undo, redo} from "prosemirror-history"

class CodeBlockView {
    constructor(node, view, getPos) {
        // Store for later
        this.node = node
        this.view = view
        this.getPos = getPos

        // Create a CodeMirror instance
        this.cm = new CodeMirror({
            doc: this.node.textContent,
            extensions: [
                cmKeymap.of([
                    ...this.codeMirrorKeymap(),
                    ...defaultKeymap
                ]),
                drawSelection(),
                syntaxHighlighting(defaultHighlightStyle),
                javascript(),
                CodeMirror.updateListener.of(update => this.forwardUpdate(update))
            ]
        })

        // The editor's outer node is our DOM representation
        this.dom = this.cm.dom

        // This flag is used to avoid an update loop between the outer and
        // inner editor
        this.updating = false
    }
// }
// nodeview_forwardUpdate{
    forwardUpdate(update) {
        if (this.updating || !this.cm.hasFocus) return
        let offset = this.getPos() + 1, {main} = update.state.selection
        let selFrom = offset + main.from, selTo = offset + main.to
        let pmSel = this.view.state.selection
        if (update.docChanged || pmSel.from != selFrom || pmSel.to != selTo) {
            let tr = this.view.state.tr
            update.changes.iterChanges((fromA, toA, fromB, toB, text) => {
                if (text.length)
                    tr.replaceWith(offset + fromA, offset + toA,
                        schema.text(text.toString()))
                else
                    tr.delete(offset + fromA, offset + toA)
                offset += (toB - fromB) - (toA - fromA)
            })
            tr.setSelection(TextSelection.create(tr.doc, selFrom, selTo))
            this.view.dispatch(tr)
        }
    }
// }
// nodeview_setSelection{
    setSelection(anchor, head) {
        this.cm.focus()
        this.updating = true
        this.cm.dispatch({selection: {anchor, head}})
        this.updating = false
    }
// }
// nodeview_keymap{
    codeMirrorKeymap() {
        let view = this.view
        return [
            {key: "ArrowUp", run: () => this.maybeEscape("line", -1)},
            {key: "ArrowLeft", run: () => this.maybeEscape("char", -1)},
            {key: "ArrowDown", run: () => this.maybeEscape("line", 1)},
            {key: "ArrowRight", run: () => this.maybeEscape("char", 1)},
            {key: "Ctrl-Enter", run: () => {
                    if (!exitCode(view.state, view.dispatch)) return false
                    view.focus()
                    return true
                }},
            {key: "Ctrl-z", mac: "Cmd-z",
                run: () => undo(view.state, view.dispatch)},
            {key: "Shift-Ctrl-z", mac: "Shift-Cmd-z",
                run: () => redo(view.state, view.dispatch)},
            {key: "Ctrl-y", mac: "Cmd-y",
                run: () => redo(view.state, view.dispatch)}
        ]
    }

    maybeEscape(unit, dir) {
        let {state} = this.cm, {main} = state.selection
        if (!main.empty) return false
        if (unit == "line") main = state.doc.lineAt(main.head)
        if (dir < 0 ? main.from > 0 : main.to < state.doc.length) return false
        let targetPos = this.getPos() + (dir < 0 ? 0 : this.node.nodeSize)
        let selection = Selection.near(this.view.state.doc.resolve(targetPos), dir)
        let tr = this.view.state.tr.setSelection(selection).scrollIntoView()
        this.view.dispatch(tr)
        this.view.focus()
    }
// }
// nodeview_update{
    update(node) {
        if (node.type != this.node.type) return false
        this.node = node
        if (this.updating) return true
        let newText = node.textContent, curText = this.cm.state.doc.toString()
        if (newText != curText) {
            let start = 0, curEnd = curText.length, newEnd = newText.length
            while (start < curEnd &&
            curText.charCodeAt(start) == newText.charCodeAt(start)) {
                ++start
            }
            while (curEnd > start && newEnd > start &&
            curText.charCodeAt(curEnd - 1) == newText.charCodeAt(newEnd - 1)) {
                curEnd--
                newEnd--
            }
            this.updating = true
            this.cm.dispatch({
                changes: {
                    from: start, to: curEnd,
                    insert: newText.slice(start, newEnd)
                }
            })
            this.updating = false
        }
        return true
    }
// }
// nodeview_end{

    selectNode() { this.cm.focus() }
    stopEvent() { return true }
}
// }
// arrowHandlers{
import {keymap} from "prosemirror-keymap"

function arrowHandler(dir) {
    return (state, dispatch, view) => {
        if (state.selection.empty && view.endOfTextblock(dir)) {
            let side = dir == "left" || dir == "up" ? -1 : 1
            let $head = state.selection.$head
            let nextPos = Selection.near(
                state.doc.resolve(side > 0 ? $head.after() : $head.before()), side)
            if (nextPos.$head && nextPos.$head.parent.type.name == "code_block") {
                dispatch(state.tr.setSelection(nextPos))
                return true
            }
        }
        return false
    }
}

const arrowHandlers = keymap({
    ArrowLeft: arrowHandler("left"),
    ArrowRight: arrowHandler("right"),
    ArrowUp: arrowHandler("up"),
    ArrowDown: arrowHandler("down")
})
// }

// editor{
import {EditorState, Selection, TextSelection} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {DOMParser} from "prosemirror-model"
import {exampleSetup} from "prosemirror-example-setup"

window.view = new EditorView(document.querySelector("#editor"), {
    state: EditorState.create({
        doc: DOMParser.fromSchema(schema).parse(document.querySelector("#content")),
        plugins: exampleSetup({schema}).concat(arrowHandlers)
    }),
    nodeViews: {code_block: (node, view, getPos) => new CodeBlockView(node, view, getPos)}
})
// }

  1. HTML (index.html):
<!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>
  .CodeMirror {
    border: 1px solid #eee;
    height: auto;
  }
  .CodeMirror pre { white-space: pre !important }
</style>
<body>
<div id="editor"></div>

<div id=content style="display: none">
  <h3>The code block is a code editor</h3>
  <p>This editor has been wired up to render code blocks as instances of
    the <a href="http://codemirror.net">CodeMirror</a> code editor, which
    provides syntax highlighting, auto-indentation, and similar.</p>
  <pre>
function max(a, b) {
  return a > b ? a : b
}</pre>
  <p>The content of the code editor is kept in sync with the content of
    the code block in the rich text editor, so that it is as if you're
    directly editing the outer document, using a more convenient
    interface.</p>
</div>
</body>
</html>

  1. package.json Configuration:
{
  "name": "prosemirror",
  "version": "1.0.0",
  "description": "",
  "main": "dist/prosemirror-bundle.min.js",
  "module": "src/js/main.js",
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "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"
  },
  "dependencies": {
    "@codemirror/commands": "^6.3.3",
    "@codemirror/lang-javascript": "^6.2.1",
    "@codemirror/language": "^6.10.0",
    "@codemirror/view": "^6.23.0",
    "prosemirror-commands": "^1.5.2",
    "prosemirror-history": "^1.3.2",
    "prosemirror-keymap": "^1.2.2",
    "prosemirror-model": "^1.19.4",
    "prosemirror-schema-basic": "^1.2.2"
  }
}

  1. Rollup Configuration (rollup.config.js):
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';

export default {
    input: 'src/main.js', // Entry point for the bundle
    output: {
        file: 'dist/prosemirror-bundle.min.js', // Output file path and name
        format: 'umd', // Format of the output bundle (Universal Module Definition)
        name: 'ProseMirror' // Name of the exported global variable
    },
    plugins: [
        resolve({
            browser: true, // Resolve modules for browser environment
            preferBuiltins: false // Do not prefer built-in modules over local modules
        }),
        commonjs(), // Convert CommonJS modules to ES6
        replace({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), // Replace NODE_ENV variable with 'development' (or current environment)
            preventAssignment: true // Prevent replacement of variable assignment
        }),
        (process.env.NODE_ENV === 'production' && terser()) // If in production environment, minify the code
    ]
};

Could you please help me understand what might be going wrong? Is there a specific approach or method I should be using that I might have missed?

Thank you for your time and assistance.

This can happen when your node_modules directory contains multiple instances of prosemirror-state. Clearing it (and your package lock) and reinstalling usually fixes that.

I deleted node_modules and pnpm-lock.yaml, and then reran pnpm install and pnpm build, but it still throws an error: Uncaught RangeError: Adding different instances of a keyed plugin (plugin$).

20240107-044803

It seems there is some conflict between arrowHandlers and exampleSetup({schema}). When I change it as follows, there is no error.

Most likely your keymap is being assigned a plugin key that also exists within basicSetup, but that still points at prosemirror-state somehow being loaded twice. Try to debug what your bundler or module loader is doing.

1 Like