Skip to content

handleSelectionUpdate in VueNodeViewRenderer breaks use flow #7647

@SionGrey

Description

@SionGrey

Affected Packages

@tiptap/vue-3 (maybe vue-2 and react affected too, can't check)

Version(s)

3.20.4

Bug Description

After updating from tiptap 2 to tiptap 3 my custom notion-like paragraph extensions using custom node view started behaving strange on command-A selection. After some digging and looking at commit history, I found cause. It was added to VueNodeViewRenderer class handleSelectionUpdate handler, which forcefully trigger selectNode method, so instead of selected text, we now have selected node, resulting unexpected visual and behaviour. I suggest at least add checking selectable prop of node extension for this handler (right now it is ignored).

Browser Used

Chrome

Code Example URL

No response

Expected Behavior

Return to selectText behaviour or at least control this with selectable prop

Additional Context (Optional)

wrote this extension fixing this problem for me (I extended in my project with custom view nodes in CUSTOM_NODE_TYPES var):

/* eslint-disable @typescript-eslint/naming-convention */
import { Extension } from '@tiptap/vue-3';
import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state';

const PLUGIN_KEY = new PluginKey('selectAllTextSelection');
const META_APPLIED = 'selectAllTextSelectionApplied';
const CUSTOM_NODE_TYPES = ['paragraph', 'heading', 'bulletList', 'orderedList'];

/**
 * TipTap 3's VueNodeViewRenderer.handleSelectionUpdate() adds the
 * `ProseMirror-selectednode` CSS class to every node view whose range is
 * encompassed by the current selection — even for TextSelection / AllSelection.
 * This is a behaviour change from TipTap 2 that causes nodes to *look*
 * node-selected when the user simply Cmd+A-selects all text.
 *
 * Three-layer fix:
 *  1. handleKeyDown  – intercepts Mod-a and creates a TextSelection instead
 *     of the default AllSelection, which already reduces the blast radius.
 *  2. appendTransaction – converts any real NodeSelection on our custom-view
 *     node types back to a TextSelection within the node.
 *  3. onSelectionUpdate + queueMicrotask – removes the ProseMirror-selectednode
 *     class that handleSelectionUpdate adds for non-NodeSelection selections.
 *     The microtask runs after all synchronous selectionUpdate listeners
 *     (including VueNodeView's) but before the browser paints.
 */
export const SelectAllTextSelection = Extension.create({
    name: 'selectAllTextSelection',

    onSelectionUpdate() {
        const { editor } = this;
        const { selection } = editor.state;

        if (selection instanceof NodeSelection) {
            return;
        }

        queueMicrotask(() => {
            if (editor.isDestroyed) {
                return;
            }

            editor.view.dom.querySelectorAll('.ProseMirror-selectednode').forEach((el) => {
                el.classList.remove('ProseMirror-selectednode');
            });
        });
    },

    addProseMirrorPlugins() {
        return [
            new Plugin({
                key: PLUGIN_KEY,

                props: {
                    handleKeyDown(view, event) {
                        if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.code === 'KeyA') {
                            const { state } = view;
                            const { doc } = state;
                            const from = Selection.atStart(doc).from;
                            const to = Selection.atEnd(doc).to;
                            const selection = TextSelection.create(doc, from, to);
                            const tr = state.tr.setSelection(selection).setMeta(META_APPLIED, true);
                            view.dispatch(tr);
                            event.preventDefault();
                            return true;
                        }
                        return false;
                    },
                },

                appendTransaction(transactions, _oldState, newState) {
                    if (transactions.some((tr) => tr.getMeta(META_APPLIED))) {
                        return null;
                    }

                    const { selection } = newState;

                    if (selection instanceof NodeSelection && CUSTOM_NODE_TYPES.includes(selection.node.type.name)) {
                        const from = selection.from + 1;
                        const to = selection.from + selection.node.nodeSize - 1;

                        return newState.tr
                            .setSelection(TextSelection.create(newState.doc, from, to))
                            .setMeta(META_APPLIED, true);
                    }

                    return null;
                },
            }),
        ];
    },
});

Dependency Updates

  • Yes, I've updated all my dependencies.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions