Custom-title Element in ProseMirror: Unexpected Content Shift on Deletion

I am experiencing an unexpected behavior with a custom block-level element, custom-title, in my ProseMirror setup. This element is intended to mirror the behavior of standard HTML block elements like h1 and p.

// :: Object
// [Specs](#model.NodeSpec) for the nodes defined in this schema.
export const nodes: any = {
  // :: NodeSpec The top level document node.
  doc: {
    content: "customTitle block+",
  },

  // :: NodeSpec A title textblock, with a `level` attribute that
  // should hold the number 1. Parsed and serialized as `<custom-title>` elements.
  customTitle: {
    attrs: {
      level: { default: 1 },
      ychange: { default: null },
      id: { default: null },
      "data-toc-id": { default: null },
    },
    content: "inline*",
    group: "customTitleBlock",
    defining: true,
    parseDOM: [
      {
        tag: "custom-title",
        getAttrs: (dom: any) => ({
          level: dom.getAttribute("level") || 1,
          id: dom.getAttribute("id"),
          "data-toc-id": dom.getAttribute("data-toc-id"),
        }),
      },
    ],
    toDOM(node: any) {
      const ychangeAttrs = calcYchangeDomAttrs(node.attrs);
      const domAttrs: any = {
        ...ychangeAttrs,
      };
      if (node.attrs.id) {
        domAttrs.id = node.attrs.id;
      }
      if (node.attrs["data-toc-id"]) {
        domAttrs["data-toc-id"] = node.attrs["data-toc-id"];
      }

      return ["custom-title", domAttrs, 0];
    },
  },

  // :: NodeSpec A plain paragraph textblock. Represented in the DOM
  // as a `<p>` element.
  paragraph: {
    attrs: {
      ychange: { default: null },
      align: { default: null }, 
    },
    content: "inline*",
    group: "block",
    parseDOM: [
      {
        tag: "p",
        getAttrs: (node: any) => ({
          align: node.style.textAlign || null, 
        }),
      },
    ],
    toDOM(node: any) {
      const ychangeAttrs = calcYchangeDomAttrs(node.attrs.ychange);
      const alignStyle = node.attrs.align
        ? { style: `text-align: ${node.attrs.align}` }
        : {};

      return ["p", { ...ychangeAttrs, ...alignStyle }, 0];
    },
  },

  // :: NodeSpec A heading textblock, with a `level` attribute that
  // should hold the number 1 to 6. Parsed and serialized as `<h1>` to
  // `<h6>` elements.
  heading: {
    attrs: {
      level: { default: 1 },
      ychange: { default: null },
      id: { default: null },
      "data-toc-id": { default: null },
    },
    content: "inline*",
    group: "block",
    defining: true,
    parseDOM: [
      { tag: "h1", attrs: { level: 1 } },
      { tag: "h2", attrs: { level: 2 } },
      { tag: "h3", attrs: { level: 3 } },
      { tag: "h4", attrs: { level: 4 } },
      { tag: "h5", attrs: { level: 5 } },
      { tag: "h6", attrs: { level: 6 } },
    ],
    toDOM(node: any) {
      const ychangeAttrs = calcYchangeDomAttrs(node.attrs);

      const domAttrs: any = {
        ...ychangeAttrs,
      };

      if (node.attrs.id) {
        domAttrs.id = node.attrs.id;
      }
      if (node.attrs["data-toc-id"]) {
        domAttrs["data-toc-id"] = node.attrs["data-toc-id"];
      }

      return ["h" + node.attrs.level, domAttrs, 0];
    },
  },

Issue Description:

  • The specific issue arises when the content within custom-title is deleted. Unlike standard elements such as h1, where the subsequent p element remains in its place, in the case of custom-title, the following p element content unexpectedly shifts or moves in a way that is inconsistent with the behavior of standard HTML elements.
  1. Before delete

  2. After delete

  • Changing the tag from custom-title to h1 seems to resolve this issue, highlighting a discrepancy in how ProseMirror handles custom versus standard elements.
customTitle: {
    attrs: {
      level: { default: 1 },
      ychange: { default: null },
      id: { default: null },
      "data-toc-id": { default: null },
    },
    content: "inline*",
    group: "customTitleBlock",
    defining: true,
    parseDOM: [
      {
        tag: "h1",
        getAttrs: (dom: any) => ({
          level: dom.getAttribute("level") || 1,
          id: dom.getAttribute("id"),
          "data-toc-id": dom.getAttribute("data-toc-id"),
        }),
      },
    ],
    toDOM(node: any) {
      const ychangeAttrs = calcYchangeDomAttrs(node.attrs);
      const domAttrs: any = {
        ...ychangeAttrs,
      };
      if (node.attrs.id) {
        domAttrs.id = node.attrs.id;
      }
      if (node.attrs["data-toc-id"]) {
        domAttrs["data-toc-id"] = node.attrs["data-toc-id"];
      }

      return ["h1", domAttrs, 0];
    },
  },
  1. Before delete
  2. After delete

Attempts Made:

  • Ensured custom-title is set as a block-level element (display: block;).
custom-title {
  display: block;
  font-size: 22pt;
  font-weight: bold;
  line-height: 2.5;
}

Questions:

  1. What might be causing the custom-title element to behave differently from standard elements like h1 in terms of post-deletion content reflow?
  2. Are there any recommended practices in ProseMirror for ensuring custom elements behave similarly to standard HTML block elements?

This appears to be a thing Firefox does. When you backspace out the last letter in a regular block element, it keeps the element. When you do the same in an inline element or a custom element, even if it is styled display: block, it appears to just remove the entire element. Were you testing on Firefox as well? I’m not seeing this happen on Chrome 119, unless I don’t style the element as a block.

Yes, I did indeed set this up in Firefox. To avoid this issue, I have switched to using div elements and now define the styles of the custom nodes through classes. Thank you for your response