Disallow inline images

In a typical document, images (inline elements) can be placed anywhere. As a constraint for my documents, I would like images to be (at least conceptually) block-level, having their own dedicated blocks. My initial attempts at this forced them to be block nodes, which gets the job mostly done. However, when pasting content copied from elsewhere, any image elements nested within other elements are dropped.

I found this unresolved Discuss post on the matter: How to deal with with nodes being dropped during parsing

What I think I would like to have happen is for inline images to be converted to blocks and hoisted to the root level of the document. But I am not sure how to accomplish that. I am also not sure if there are better and/or more feasible solutions out there.

@jneander We have normalization rules that opportunistically lift the images to be block level but we also skip parsing textblocks if they have a direct child that contains an image. These two changes seem to work across the board and don’t cause data loss, but under certain conditions when pasting you could lose textblock level attributes. For example

<p style="text-align:center">Some<img src="someImage.jpg" />content</p>

In this structure, you would skip parsing the “p” tag because you would notice that it has a direct child that is an image. “Some” and “content” would be auto wrapped in a text block because they are text nodes but you would lose the ability to parse the text-align style as an attribute.

I think ideally at this point, this case would be handled inside of Prosemirror. Specifically I believe this comment by Marijn is correct answer given no technical constraints.

1 Like

Is the appropriate solution here to follow through and make the non-trivial change to ProseMirror’s default DOM parser? If that is the case, I can attempt to make that change in a fork and submit a pull request. Though, I am still working to grok PM overall and would probably take a while to figure it out.

I think that is the appropriate solution but I’m really not sure how to go about attempting to implement it. I would default to whatever @marijn thinks here.

So the use case is people pasting content with inline images wrapped in, say, paragraphs, but you want the parser to treat those as top-level?

Would it be reasonable to implement this as a pre-DOMParser transformation on the HTML or DOM (via transformPastedHTML), instead of modifying the DOMParser?

I support any solution which can mitigate the dropped node issue, including compromising about where images are allowed (I ended up doing this to unblock myself for now). A custom parser would get the job done. Though, it seems like the existing parser could be leveraged instead of being replaced or re-implementing its behavior.

Is there a place in the parsing sequence where already-parsed HTML can be modified before it is validated/corrected in the later transformation steps?

So transformPastedHTML would allow you to change the HTML text before it is fed to the regular DOM parser. You could parse it into a DOM structure at that point, run your transformations, and serialize it to text again. (That sounds inefficient, but pasting doesn’t happen with a high enough frequency for that to matter).

Hello, maybe not the most elegant and it doesn’t take all the different cases but it works in most cases.

Code in Typescript so that’s why there are a lot of type checking:

function findImageNodeIndexInChildren(element: HTMLElement) {
  for(let i = 0; i < element.childNodes.length; i++) {
    if (element.childNodes.item(i).nodeName === 'IMG') {
      return i;
    }
  }
  return null;
}

function processImageNode(image: Element) {
  const parent = image.parentElement;
  const grandParent = parent?.parentElement;
  if (!parent || !grandParent) return;
  if (parent.nodeName === 'P' || parent.nodeName === 'DIV') {
    let imageIndex = findImageNodeIndexInChildren(parent);
    if (imageIndex === null) return;
    // Move all nodes before the image in a parent before the current one
    if (imageIndex > 1) {
      const pBefore = document.createElement(parent.nodeName);
      grandParent.insertBefore(pBefore, parent);
      for (; imageIndex > 0; imageIndex--) {
        pBefore.append(parent.childNodes.item(0));
      }
    }
    // Move the image before the current parent
    const imageNode = parent.childNodes.item(0);
    grandParent.insertBefore(imageNode, parent);
    // If the old parent is now empty, delete it
    if (parent.childNodes.length === 0) {
      grandParent.removeChild(parent);
    }
  } else {
    parent.replaceWith(image)
  }
}

function unwrapImages(htmlString: string) {
  const div = document.createElement('div');
  div.className = "root"
  div.innerHTML = htmlString;

  // Security to not loop too deep
  let maxLevel = 5;
  let images;
  do {
    images = div.querySelectorAll("*:not(.root) > img");
    for (let i = 0; i < images.length; i++) {
      processImageNode(images[i]);
    }
    maxLevel--;
  } while (maxLevel > 0 && images.length > 0);
  return div.innerHTML;
}

Feel free to improve it and share it back :slight_smile:

1 Like