Schema for figure / structured element

In my use case, I want all images to be wrapped in a block-level figure, like

<figure>
  <img src="..." />
  <figcaption>
    <h1>title</h1>
    <p>description</p>
  </figcaption>
</figure>

If title / description don’t exist, then placeholder text can show. There should always be exactly those two blocks in the figcaption. They should function like form fields but feel like free text to navigate between them.

Is this kind of structured editing within PM’s scope? I read the dino demo for extending the schema, but this would be more complicated. Could we work through extending schema for this use?

Made some headway today. It seems to render correctly now, but doesn’t accept any typing:

import {Block, Attribute} from 'prosemirror/src/model'
import {elt} from 'prosemirror/src/dom'

export function makeFigureDom (attrs) {
  let {src, title, description} = attrs
  if (!src) return
  let element = elt('figure', {},
    elt('img', {src}),
    elt('figcaption', {},
      elt('h1', {}, (title || '')),
      elt('p', {}, (description || ''))
    )
  )
  return element
}

export class Figure extends Block {}
Figure.attributes = {
  src: new Attribute('src'),
  title: new Attribute('title'),
  description: new Attribute('description')
}

Figure.register('parseDOM', {
  tag: 'figure',
  rank: 25,
  parse: (dom, context, nodeType) => {
    let src = dom.querySelector('img').src
    let title = dom.querySelector('h1').innerHTML
    let description = dom.querySelector('p').innerHTML
    if (!src) return false
    context.insert(nodeType.create({src, title, description}))
  }
})
Figure.prototype.serializeDOM = node => makeFigureDom(node.attrs)
1 Like

What you’re doing is a possible direction, but you’d have to manage the editing of the title and description yourself (though some other UI). ProseMirror doesn’t currently support this use case yet, but it is on the roadmap. The plan is to allow ‘locked’ nodes, whose structure can not change in the normal ways, and represent your figure node as a locked node with two child nodes, one heading and one paragraph. That way, the elements children are normal parts of the document and can be edited, but you can’t add, for example, another paragraph to your figure.

This is going to happen relatively soon (I also need it for tables), but will be quite a lot of work (all editing actions have to take the existence of locked nodes into account, and adjust their behavior accordingly).

1 Like

That’s cool. A modal UI is what we have now, but this will be nicer.

Ok, cool (about upcoming change). I think we also need that for footnotes. I was thinking of implementing footnotes for now using some UI of our own, but it would mean the footnotes would contain plaintext only, which probably wouldn’t be ok for users. So I’ll wait instead.

When updating node’s from a outside ProseMirror, is there a good way to update the attrs of a node? I currently have a figure node and want to use a modal to change the caption among other things. Would be cool if there was something like node.attrs.caption = "new"; node.apply().

My current solution is to try and delete the node and insert it anew which is a bit painful as I have to select the node first.

I’m also struggling a bit with “Error: Index 1 out of range in Figure()”, as my figure seems to have a valid path but no valid offset and is unselectable.

Hey, yes same here.

Yes, check these replies from Marijn:

So I am doing that. But trying to find the correct Pos of a node one already has is a bit strange. I am currently using this function (for inline nodes): https://github.com/fiduswriter/prosemirror-footnotes/blob/gh-pages/src/footnote-pm.js#L28-L55 . It seems awkward that one should have to go through this. Maybe we have misunderstood something?

I decided we don’t want locked nodes, and will allow freely-editable figcaption.

I had to copy a bunch of utils from serialize/dom.js to define the schema. Is there way with less boilerplate? Edit 12/8: with changes pushed today there is less copying serialize stuff. Specifically, Figure.prototype.serializeDOM is called with the DOMSerializer class as second argument: https://github.com/the-grid/ed/commit/1a1818c850aef5c7eb68eb59d02f758939f2c3da

The img is automatically wrapped by a p … is there a way to make that wrapper contenteditable=false? We won’t allow inline images, so if there is an image eg in pasted html we’ll want to split it out to a figure.

I think I need some more logic for “entering out” of the figcaption. I’m ending up with another figcaption in the figure when I expect a new p after the figure.

Live demo: https://the-grid.github.io/ed/

1 Like

Working with it more, I guess I still need locked nodes: all images should be in:

<figure> <!-- contains excactly one img and one figcaption -->
  <div contenteditable="false">
    <img src="editable from tooltip" />
  </div>
  <figcaption>
    <!-- freely editable block (but no img) -->
  </figcaption>
</figure>

This is now more well-factored, and the DOMSerializer object that your serializers get access to exposes everything that the built-in serializers use (it’s even documented!).

Again, making locked nodes work is on my roadmap, but not done yet, and I don’t think there is a solid solution to what you are trying to do here with today’s existing tools.

Ya! Got them down to:

Media.register('parseDOM', {tag: 'figure', parse: 'block'})

and

Media.prototype.serializeDOM = (node, s) => s.renderAs(node, 'figure')
1 Like

5.5 years later: new project, same question. If I want to limit images to the following structure, what’s a good place to start with 2021 Prosemirror?

I’m using as my nodespecs:

  {
      content: "image+ figcaption?",
      group: "block",
      parseDOM: [{
        tag: "figure",
        getAttrs(dom: HTMLElement) {
          return dom.querySelector("img[src]") ? {} : false; // check for an image element
        },
      }],
      toDOM() { 
        return ["figure", 0] 
      }
    };
  {
      attrs: {src: {default: null}, alt: {default: null}, title: {default: null}, align: {default: "center"}},
      inline: false,
      group: "block",
      draggable: true,
      parseDOM: [{
        tag: "img[src]", 
        getAttrs(img: HTMLImageElement) { 
          return {src: img.src, alt: img.alt, title: img.title, align: img.dataset.align}; 
        }
      }],
      toDOM(node: PMNode) { 
        const {align, ...attrs} = node.attrs; 
        return ["img", {'data-align': align, ...attrs}]; 
      }
    }
    {
      content: "inline*",
      group: "figure",
      parseDOM: [{tag: "figcaption"}],
      toDOM() { return ["figcaption", 0]; },
    };