Unexpected selection behavior on mouse click within and outside an existing selection

Hello,

I am experiencing a peculiar issue related to selection behavior when using mouse clicks inside and outside of an existing selection. I am using ProseMirror with the basic schema and the Vue.js framework.

Issue Description:

  • When I click within an existing selection range (from, to), I expect the editor state to be updated such that from equals to, since the mouse click should collapse the selection to a single point. However, the actual outcome is from not equal to to.
  • When I click outside of the existing selection range, the editor state is updated as expected, with from equals to.

Code:

The relevant Vue.js code snippet that I am using is provided below:

<template>
  <div ref="editorRef" @click="onClick"></div>
  <div id="content" style="display: none;" >Hello ProseMirror</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { EditorState,TextSelection } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Schema, DOMParser } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import { exampleSetup } from 'prosemirror-example-setup';

const editorRef = ref<HTMLElement | null>(null);
const editor = ref<EditorView | null>(null);

onMounted(() => {
  const mySchema = new Schema({
    nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
    marks: schema.spec.marks
  });

  if (editorRef.value) {
    const elementNode  = document.querySelector("#content")
    if (elementNode)  editor.value =new EditorView(editorRef.value, {
      state: EditorState.create({
        doc: DOMParser.fromSchema(mySchema).parse(elementNode),
        plugins: exampleSetup({schema: mySchema})
      })
    });
  }
});

const onClick = () => {
  const { doc, selection } = editor?.value?.state
  const { from, to, empty } = selection
  console.log("from.to",from,to)
}
</script>

I’d appreciate it if you could take a look and let me know if there is something I am missing or if this is an actual issue. Thanks in advance!

Can you reproduce the issue on prosemirror.net’s demo editor? If so, which browser and platform are you using? And if not, could you simplify your example code to not include Vue or other irrelevant libraries?

I’ve written an HTML version of the code (ESM module version) as follows:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ProseMirror Editor</title>
    <link rel="stylesheet" href="editor.css">
    <script type="module">
        // Import the ProseMirror modules
        import { EditorState, TextSelection } from 'https://cdn.skypack.dev/prosemirror-state';
        import { EditorView } from 'https://cdn.skypack.dev/prosemirror-view';
        import { Schema, DOMParser } from 'https://cdn.skypack.dev/prosemirror-model';
        import { schema } from 'https://cdn.skypack.dev/prosemirror-schema-basic';
        import { addListNodes } from 'https://cdn.skypack.dev/prosemirror-schema-list';
        import { exampleSetup } from 'https://cdn.skypack.dev/prosemirror-example-setup';

        const editorElement = document.querySelector('#editor');
        const contentElement = document.querySelector('#content');

        const mySchema = new Schema({
            nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
            marks: schema.spec.marks
        });

        let editor = null;

        if (editorElement && contentElement) {
            editor = new EditorView(editorElement, {
                state: EditorState.create({
                    doc: DOMParser.fromSchema(mySchema).parse(contentElement),
                    plugins: exampleSetup({ schema: mySchema })
                })
            });

            editorElement.addEventListener('click', function() {
                const { doc, selection } = editor.state;
                const { from, to, empty } = selection;
                console.log("from.to", from, to);
            });
        }
    </script>
</head>
<body>
<div id="editor"></div>
<div id="content" style="display: none;">Hello ProseMirror</div>
</body>
</html>

I have tested this on MacBookPro M1 Chrome(114.0.5735.133 (arm64)), Edge(115.0.1901.188 (arm64)), Firefox(115.0.1 (64 bit)) and Safari(16.5 (18615.2.9.11.4)) browsers. The problem still exists.

You’re still using a CDN. That generally doesn’t work because most CDNs are dumb about diamond dependencies (where multiple direct dependencies end up pulling in the same transitive dependency), and duplicate them.

I’ve switched to using pnpm modules and bundling with rollup.js. The code for index.html, main.js, and rollup.config.js are as follows:

index.html:

<!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>
<body>
<!--<div id="mad_interactive_editor"></div>-->
<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>

main.js:

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';

// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
    nodes: addListNodes(basicSchema.spec.nodes, 'paragraph block*', 'block'),
    marks: basicSchema.spec.marks,
});

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


window.addEventListener('click', function() {
    const { doc, selection } = window.view.state;
    const { from, to, empty } = selection;
    console.log("from.to", from, to);
});

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: 'public/js/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
    ]
};

I bundle with rollup --config , but after deploying, the problem on the page is still the same. This time I believe I’ve ruled out the factor of CDN. I wonder if there’s something wrong with my approach?

Did you ever test on prosemirror.net to see if the same problem exists there?

I’ve just tested it on https://prosemirror.net/, and I encountered the same issue.

When I (on both macOS Chrome and Safari), go to prosemirror.net, create a selection, and then click somewhere inside that selection, I do see the selection replaced by a cursor at the point of the click, which seems the appropriate behavior.

You are correct; my observation matches what you described. However, on the program side, I notice that the values of ‘from’ and ‘to’ within the click event (as logged by console.log) remain different, even though the text selection has been replaced by a cursor. I would expect the values of ‘from’ and ‘to’ to become the same once the text selection is replaced by a cursor, but the values printed by console.log for ‘from’ and ‘to’ remain the same as they were during the text selection operation.

I see. At the time of the click event, the editor apparently hasn’t received the selectionchange event and hasn’t updated their selection state yet.

1 Like

I ran into this same issue using TipTap, and believe the underlying issue is somewhere in Prosemirror.

I spun up a minimal reproducible example here:

The demo uses a very simple plugin to just log selection changes in Plugin.view.update:

function DemoPlugin() {
  return new Plugin({
    view() {
      return {
        update(view, prevState) {
          console.log(`prev: ${prevState.selection.from} ...`);
        }
      };
    }
  });
}

Here’s a video demonstrating the issue:

Some notes on the issue:

  • This only occurs when you click inside a span of selected text
  • I expect the update() method to be called with a new selection
  • After clicking inside the selection (which clears it), if you click again I then receive the new, correct selection
  • The behavior only occurs with editable: () => false

I believe we may be hitting some form of this as well.

I’m trying to implement mixed selections of text and images and I’m experiencing this also. If we click the boundary of our selection in the text, the selection fails to collapse.

Unfortunately because prosemirror.net implements the image as an inline node we can’t attempt to reproduce the issue there.