ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
26.85k stars 2.24k forks source link

[Bug]: NodePos.pos different from actual PM pos #5672

Open rChaoz opened 3 days ago

rChaoz commented 3 days ago

Affected Packages

core

Version(s)

2.7.3

Bug Description

The value in NodePos seems to be different than the "real" ProseMirror pos (larger by 1).

Browser Used

Firefox

Code Example URL

https://codesandbox.io/p/sandbox/lively-grass-pqndvk

Expected Behavior

The positions are equal

Additional Context (Optional)

TipTap pos is 1 larger than actual PM pos

Dependency Updates

marczi commented 3 days ago

We also experienced the same bug, but create our own layer as a workaround. It tries to mimics NodePos, but uses a logic that matches with PM's pos values. If our code helps in any way, feel free to use it:

import { Editor } from "@tiptap/core";
import { Node, ResolvedPos } from "@tiptap/pm/model";
import { Transaction } from "@tiptap/pm/state";

class Selector
{
    private typeName?: string;
    private attributeKey?: string;
    private attributeValue?: string;

    constructor(selector: string)
    {
        selector = selector.trim();
        const pAttr = selector.indexOf('[');
        if (pAttr >= 0 && selector.endsWith(']')) {
            const attr = selector.substring(pAttr + 1, selector.length - 1).trim();
            selector = selector.substring(0, pAttr).trim();
            const pAttrValue = attr.indexOf('=');
            if (pAttrValue >= 0) {
                this.attributeKey = attr.substring(0, pAttrValue).trim();
                this.attributeValue = attr.substring(pAttrValue + 1).trim();
            }
            else {
                this.attributeKey = attr.trim();
            }
        }

        if (selector.length > 0) {
            this.typeName = selector;
        }
    }

    public matches(node: Node): boolean
    {
        if (this.typeName && node.type.name !== this.typeName) {
            return false;
        }
        if (this.attributeKey) {
            if (this.attributeValue) {
                return node.attrs[this.attributeKey] === this.attributeValue;
            }
            return this.attributeKey in node.attrs;
        }
        return true;
    }
}

export class ResolvedNodePos
{
    public static readonly DOC_POS = -1;

    private readonly editor: Editor;
    public readonly node: Node | null = null;
    public readonly pos: number;

    public static atRoot(editor: Editor): ResolvedNodePos
    {
        return new ResolvedNodePos(editor, ResolvedNodePos.DOC_POS);
    }

    public static atCursor(editor: Editor): ResolvedNodePos
    {
        return new ResolvedNodePos(editor, editor.state.selection.from);
    }

    public static at(editor: Editor, pos: number): ResolvedNodePos
    {
        return new ResolvedNodePos(editor, pos);
    }

    private constructor(editor: Editor, pos: number, node?: Node)
    {
        this.editor = editor;
        this.pos = pos;
        if (node) {
            this.node = node;
        }
        else {
            this.node = pos === ResolvedNodePos.DOC_POS ? editor.state.doc : editor.state.doc.nodeAt(pos);
            // If we are not directly at the node, we need to resolve it
            if (this.node === null) {
                this.node = editor.state.doc.resolve(pos).node();
            }
        }
    }

    public get size(): number
    {
        return this.node ? this.node.nodeSize : 0;
    }

    public get to()
    {
        return this.pos + this.size;
    }

    public get empty(): boolean
    {
        return this.node === null
            || (this.node.isBlock && this.node.nodeSize === 2)
            || this.node.nodeSize === 0;
    }

    public closest(selector: string): ResolvedNodePos | undefined
    {
        const sel = new Selector(selector);
        const doc = this.editor.state.doc;
        let pos = this.pos >= 0 ? this.pos : 0;

        let resolvedPos: ResolvedPos = doc.resolve(pos);
        let checkParent = true;
        while (checkParent) {
            const node = doc.nodeAt(pos);
            if (node && sel.matches(node)) {
                return new ResolvedNodePos(this.editor, pos, node);
            }

            if (resolvedPos.depth > 0) {
                pos = resolvedPos.before();
                resolvedPos = doc.resolve(pos);
            }
            else {
                checkParent = false;
            }
        }
        return undefined;
    }

    public querySelector(selector: string): ResolvedNodePos | undefined
    {
        let result: ResolvedNodePos | undefined = undefined;
        const sel = new Selector(selector);

        const startNode = this.pos === -1 ? this.editor.state.doc : this.node;

        startNode?.descendants((descendantNode, offset) => {
            if (result !== undefined) {
                return false; // Stop the search
            }
            if (sel.matches(descendantNode)) {
                result = new ResolvedNodePos(this.editor, this.pos + offset + 1, descendantNode);
                return false; // Stop the search
            }
            return true;
        });

        return result;
    }

    private addMeta(tr: Transaction, meta?: Record<string, any>): void
    {
        if (meta) {
            Object.keys(meta).forEach(key => {
                tr.setMeta(key, meta[key]);
            });
        }
    }

    public getAttribute(attributeKey: string): string | undefined
    {
        return this.node ? this.node.attrs[attributeKey] : undefined;
    }

    public setAttribute(attributeKey: string, attributeValue: any, meta?: Record<string, any>): void
    {
        if (this.node) {
            const tr = this.editor.state.tr;
            this.addMeta(tr, meta);
            tr.setNodeAttribute(this.pos, attributeKey, attributeValue);
            this.editor.view.dispatch(tr);
        }
    }

    public setAttributes(editor: Editor, pos: number, attributes: Record<string, any>, meta?: Record<string, any>): void
    {
        if (this.node) {
            const tr = this.editor.state.tr;
            this.addMeta(tr, meta);
            Object.keys(attributes).forEach(key => {
                tr.setNodeAttribute(pos, key, attributes[key]);
            });
            this.editor.view.dispatch(tr);
        }
    }
}