Selection inside mark with inline decoration

I’m trying to highlight a mark with an inline decoration whenever the selection is completely inside of said mark and am having partial success.

If the $anchor and $head are both contained within the same mark instance, selection.empty or not, then things work as expected. But as soon as the selection is not empty and the $head leaves either side of the mark, then the selection jumps to that side of the mark or just goes haywire with the decorations. The really confusing thing is that this behavior only happens when selecting with a mouse. If I use the keyboard to change the selection, then everything works as expected. I’ve added an example of each situation below. I’d appreciate any ideas that anyone might have about what I’m doing wrong.

MouseSelection

KeyboardSelection

My plugin looks like this:

import {Plugin} from 'prosemirror-state';
import {Decoration, DecorationSet} from 'prosemirror-view';
import {getMarkRange} from '@tiptap/core';

import {CommentsPluginKey} from './keys';

return new Plugin({
    key: CommentsPluginKey,

    state: {
        init() {
            return {activeCommentId: null};
        },

        apply(tr, pluginState, oldState, newState) {
            const {doc, selection} = tr;
            const {$from, to, empty} = selection;
            const $to = doc.resolve(empty ? to + 1 : to);
            const {name: markTypeName} = newState.schema.marks.comments;

            const fromMark = $from.marks().find((mark) => mark.type.name === markTypeName);
            const toMark = $to.marks().find((mark) => mark.type.name === markTypeName);

            let activeCommentId = null;

            if ((fromMark && toMark) && (fromMark === toMark)) {
                // If both ends are on a comment, check if it's the same instance of a mark,
                // not a mark that has been split or duplicated, e.g. with copy and paste
                activeCommentId = toMark.attrs.commentId;
            } else if (selection.empty && toMark) {
                // Otherwise, if the selection is empty and there's a mark at the next position, then select that one
                activeCommentId = toMark.attrs.commentId;
            }

            return {activeCommentId};
        },
    },

    props: {
        decorations(state) {
            const {activeCommentId} = this.getState(state);
            const {doc, schema} = state;
            const {name: markTypeName} = schema.marks.comments;

            if (activeCommentId === null) {
                return DecorationSet.empty;
            }

            // If there is a comment, then find all its matches across the doc
            const activeMarks = [];
            doc.descendants((node, pos) => {
                node.marks.forEach((mark) => {
                    if (mark.type.name === markTypeName
                     && mark.attrs.commentId === activeCommentId) {
                        activeMarks.push({
                            mark,
                            $pos: doc.resolve(pos),
                        });
                    }
                });
            });

            const decorations = [];
            activeMarks.forEach(({mark, $pos}) => {
                const markRange = getMarkRange($pos, mark.type, mark.attrs);

                if (markRange) {
                    decorations.push(Decoration.inline(markRange.from, markRange.to, {class: 'comment-highlight'}, {...mark.attrs}));
                }
            });

            return DecorationSet.create(doc, decorations);
        },
    },
});
1 Like

Is this on Chrome/Safari? Webkit browsers are notoriously terrible about interrupting mouse selection when the DOM changes, and it might be better to delay decoration until the drag-selection finishes.

Thanks for the response, @marijn.

The example I posted is from Chrome, but I get the same thing from Safari and Firefox. Here are the versions I have access to:

Chrome   v96.0.4664.55 
Safari   v15 (16612.1.29.41.4, 16612)
Firefox  v94.0.2 

I should add that while the selection $anchor jumped to either the beginning or the end of the mark in each browser, Firefox was the only browser that didn’t produce the flicker.

it might be better to delay decoration until the drag-selection finishes

Are there utilities or hooks in prosemirror that would help with determining a delay? I’m looking through prosemirror-view and nothing is jumping out at me. These comments feel like they’re related, but that link also feels more in-the-weeds than I should be looking.

Or were you talking about doing something manually in the plugin using mousedown and mouseup?

Yes, if possible, you can probably sidestep this issue by delaying redecoration as long as the mouse button is down.

Yeah, that idea worked well enough. There’s still some logic to clean up, but it’s definitely passable. Here’s the updated plugin and the example for anyone else that comes by this thread:

DecorationDelay

import {Plugin} from 'prosemirror-state';
import {Decoration, DecorationSet} from 'prosemirror-view';
import {getMarkRange} from '@tiptap/core';

import {CommentsPluginKey} from './keys';

return new Plugin({
    key: CommentsPluginKey,

    state: {
        init() {
            return {
                activeCommentId: null,
                maybeMouseSelecting: false,
            };
        },

        apply(tr, pluginState, oldState, newState) {
            const {doc, selection} = tr;
            const {$from, to, empty} = selection;
            const $to = doc.resolve(empty ? to + 1 : to);
            const {name: markTypeName} = newState.schema.marks.comments;

            const fromMark = $from.marks().find((mark) => mark.type.name === markTypeName);
            const toMark = $to.marks().find((mark) => mark.type.name === markTypeName);

            let activeCommentId = null;

            if ((fromMark && toMark) && (fromMark === toMark)) {
                // If both ends are on a comment, check if it's the same instance of a mark,
                // not a mark that has been split or duplicated, e.g. with copy and paste
                activeCommentId = toMark.attrs.commentId;
            } else if (selection.empty && toMark) {
                // Otherwise, if the selection is empty and there's a mark at the next position, then select that one
                activeCommentId = toMark.attrs.commentId;
            }

            const meta = tr.getMeta(CommentsPluginKey);

            let {maybeMouseSelecting} = pluginState;

            if (meta?.maybeMouseSelecting !== undefined) {
                maybeMouseSelecting = meta.maybeMouseSelecting;
            }

            return {
                activeCommentId,
                maybeMouseSelecting,
            };
        },
    },

    props: {
        handleDOMEvents: {
            mousedown(view) {
                view.dispatch(view.state.tr.setMeta(CommentsPluginKey, {maybeMouseSelecting: true}));
            },

            mouseup(view) {
                view.dispatch(view.state.tr.setMeta(CommentsPluginKey, {maybeMouseSelecting: false}));
            },
        },

        decorations(state) {
            const {activeCommentId, maybeMouseSelecting} = this.getState(state);
            const {doc, schema} = state;
            const {name: markTypeName} = schema.marks.comments;

            if (activeCommentId === null || maybeMouseSelecting) {
                return DecorationSet.empty;
            }

            // If there is a comment, then find all its matches across the doc
            const activeMarks = [];
            doc.descendants((node, pos) => {
                node.marks.forEach((mark) => {
                    if (mark.type.name === markTypeName
                     && mark.attrs.commentId === activeCommentId) {
                        activeMarks.push({
                            mark,
                            $pos: doc.resolve(pos),
                        });
                    }
                });
            });

            const decorations = [];
            activeMarks.forEach(({mark, $pos}) => {
                const markRange = getMarkRange($pos, mark.type, mark.attrs);

                if (markRange) {
                    decorations.push(Decoration.inline(markRange.from, markRange.to, {class: 'comment-highlight'}, {...mark.attrs}));
                }
            });

            return DecorationSet.create(doc, decorations);
        },
    },
});

Thanks for your time and thoughts, @marijn. Take good care.

2 Likes

May I refer to your CommentPlugins ?I now have the same need, but I’m not familiar enough with the editor。

Yes, of course. Good luck.

So, where is the full code of CommentPlugins:slightly_smiling_face: I also use TipTap

Oh, I misunderstood the question. What I’ve posted above has been abstracted out of my application. The actual code is, frankly, specific to the interactions of my application and unusable outside of my team.

What I can say is that if you’re using TipTap, you should look at:

  1. Custom extensions – Tiptap Editor
  2. commands.setMark() and commands.unsetMark() or any of the TipTap Mark extensions
  3. ProseMirror.state.PluginSpec.view. The TipTap team was creative with their BubbleMenu by creating a class to handle ProseMirror View updates (here) and then wrapping the PM Plugin instantiation in a function. This enabled them to inject the editor instance into the plugin (here), which will let you expose some internal PM stuff to your app using the addStorage() feature for the TipTap editor.

These are just a couple ideas, though. There are a lot of ways to solve this problem.

Thank you very much. Let me think about it :ok_hand: