Tiptap: Embedding image in a figure element with a figcaption

Created on 31 Dec 2019  路  28Comments  路  Source: ueberdosis/tiptap

I'm struggling with how to do this:

<figure class="is-left">
  <img src="image.jpg" alt="alt text">
  <figcaption>Caption for image goes here</figcaption>
</figure>

The things I want to achieve here is to:

  • upload an image
  • put it inside a figure element in the document
  • be able to set a class on the figure element (for example is-left, is-centered, is-right)
  • be able to write a caption for the image
  • be able to add an alt-text for the image

I guess it involves creating a schema for <figure> but I have a hard time understanding how schemas work in general and with nested elements in particular.

Has anyone done something similar to this and/or have some example code to show? I would really appreciate any help!

Most helpful comment

Hey @neelay92, I've been giving this a try today and this is what I ended up with:

commands({ type, schema }) {
  return attrs => (state, dispatch) => {
    const { tr, selection } = state
    const pos = selection.$cursor ? selection.$cursor.pos : selection.$to.pos

    const node = type.create(null, [
      schema.nodes.image.create({
        alt: attrs.caption,
        src: attrs.src,
      }),
      schema.nodes.figcaption.create(
        null,
        attrs.caption ? schema.text(attrs.caption) : 'default caption'
      ),
    ])

    dispatch(tr.insert(pos, node))
  }
}

I still have no idea why createAndFill returns null or whatever a fitting wrapping should be, but creating the components manually (and therefore being able to pass attributes) works well for me.

All 28 comments

These nodes should work for the most part, but you're going to have to modify them to do what you want to achieve:

  • upload an image
    depends on you're storing images
  • put it inside a figure element in the document
    "image figcaption" takes care of that
  • be able to set a class on the figure element (for example is-left, is-centered, is-right)
    modify the figure schema to support this
  • be able to write a caption for the image
    figcaption takes care of that
  • be able to add an alt-text for the image
    alt in image schema takes care of that

Figure.js

import { Node   } from 'tiptap'
import { Plugin } from 'prosemirror-state'

export default class Figure extends Node {

  get name() {
    return "figure";
  }

  get schema() {
    return {
      content: "image figcaption",
      group: "block",
      parseDOM: [{tag: "figure"}],
      toDOM: node => ["figure", 0],
    };
  }

  // Deletes the entire figure node if imageNode is empty.
  get plugins() {
    return [
      new Plugin ({
        appendTransaction: (transactions, oldState, newState) => {
          const tr = newState.tr
          let modified = false;
          // TO-DO: Iterate through transactions instead of descendants.
          newState.doc.descendants((node, pos, parent) => {
            if (node.type.name != "figure") return;
            let imageNode = node.firstChild;
            if (imageNode.attrs.src == imageNode.type.defaultAttrs.src &&
                imageNode.attrs.alt == imageNode.type.defaultAttrs.alt)
              {
                tr.deleteRange(pos, pos + node.nodeSize);
                modified = true;
              }
          })
          if (modified) return tr;
        }
      })
    ];
  }

}

Image.js

import { Node, Plugin } from "tiptap"
import { fs, Path, Buffer } from "filer"

export default class Image extends Node {

  get name() {
    return "image"
  }

  get schema() {
    return {
      inline: false,
      attrs: {src: {default: ""}, alt: {default: ""}},
      parseDOM: [{tag: "img", getAttrs: dom => ({src: dom.src, alt: dom.alt})}],
      toDOM: node => ["img", node.attrs],
    }
  }

  commands({ type }) {
    return attrs => (state, dispatch) => {
      const { selection } = state
      const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
      const node = type.create(attrs)
      const transaction = state.tr.insert(position, node)
      dispatch(transaction)
    }
  }

  get plugins() {
    return [
      new Plugin({
        props: {
          handleDOMEvents: {
            drop(view, event) {

              if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length)
                return

              const images = Array.from(event.dataTransfer.files).filter(file => {
                return (/image/i).test(file.type)
              })

              if (images.length == 0) return

              event.preventDefault()

              const { schema } = view.state
              const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })

              images.forEach(image => {

                // This is where you would upload image to a server or a filesystem,
                // then create an imageNode.
                const node = schema.nodes.image.create({src: XXX, alt: YYY})
                const tr = view.state.tr.insert(coordinates.pos, node)
                view.dispatch(tr)

              })

            },
          },
        },
      }),
    ]
  }

}

Figcaption.js

import {Node} from 'tiptap'

export default class Figcaption extends Node {

  get name() {
    return "figcaption"
  }

  get schema() {
    return {
      content: "inline*",
      group: "figure",
      // TO-DO: get image alt if figcaption not found using prosemirror-model
      parseDOM: [{tag: "figcaption"}],
      toDOM: node => ["figcaption", 0],
    }
  }
}

Thanks a lot @BrianHung! I will give this a try.

Hey, we are creating our editor using tiptap. Made a custom block for Image. We will going to open source it soon.

For Image

import { Image as TiptapImage } from "tiptap-extensions";
import { TextSelection } from "tiptap";

export default class Image extends TiptapImage {
  get schema() {
    return {
      attrs: {
        src: {},
        alt: {
          default: ""
        },
        caption: {
          default: ""
        }
      },
      group: "block",
      draggable: true,
      selectable: false,
      parseDOM: [
        {
          tag: "img[src]",
          getAttrs: dom => ({
            src: dom.getAttribute("src"),
            alt: dom.getAttribute("alt"),
            caption: dom.getAttribute("caption")
          })
        }
      ],
      toDOM: node => ["img", node.attrs]
    };
  }

  commands({ type }) {
    return attrs => (state, dispatch) => {
      return dispatch(state.tr.replaceSelectionWith(type.create(attrs)));
    };
  }

  get view() {
    return {
      props: ["node", "updateAttrs", "view", "getPos"],
      data() {
        return {
          editor: null
        };
      },
      watch: {
        "view.editable"() {
          this.editor.setOptions({
            editable: this.view.editable
          });
        }
      },
      computed: {
        src: {
          get() {
            return this.node.attrs.src;
          },
          set(src) {
            this.updateAttrs({
              src
            });
          }
        },
        caption: {
          get() {
            return this.node.attrs.caption;
          },
          set(caption) {
            this.updateAttrs({
              caption
            });
          }
        }
      },
      mounted() {},
      methods: {
        captionPlaceHolder() {
          return "Placeholder";
        },
        handleKeyup(event) {
          let {
            state: { tr }
          } = this.view;
          const pos = this.getPos();
          if (event.key === "Backspace" && !this.caption) {
            let textSelection = TextSelection.create(tr.doc, pos, pos + 1);
            this.view.dispatch(
              tr.setSelection(textSelection).deleteSelection(this.src)
            );
            this.view.focus();
          } else if (event.key === "Enter") {
            let textSelection = TextSelection.create(tr.doc, pos + 2, pos + 2);
            this.view.dispatch(tr.setSelection(textSelection));
            this.view.focus();
          }
        }
      },
      template: `
          <figure>
            <img :src="src" />
            <figcaption><input v-model="caption" placeholder="Type caption for image (optional)" @keyup="handleKeyup"/></figcaption>
          </figure>
        `
    };
  }
}

@BrianHung
So this works well. However if I want to set figure caption via command.image({src}), how do I achieve it. Any help would be appreciated

@neelay92

You would have to modify the commands function. I would recommend, instead of modifying command.image directly, creating a command.figure({src, caption}) in figure.js that creates (using createAndFill) and inserts a figure node into the document.

@BrianHung
Here's what I did. It seems to work. Any further optimisations would be appreciated. I am still trying to learn prosemirror and tiptap.

commands({ type }) {
        return attrs => (state, dispatch) => {
            const { selection } = state
            const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
            const node = type.createAndFill(attrs)
            // node.content.content[0].attrs.src = attrs.src;
            node.content.content[0].attrs = Object.assign({}, { src: node.attrs.src, alt: node.attrs.caption });
            const transaction = state.tr.insert(selection.anchor, node).insertText(node.attrs.caption, position + 3);
            // transaction.setMeta('addingImage', true)
            dispatch(transaction)
        }
    }

@neelay92

I haven't checked this, but try this out

// figure.js
commands({ type, schema }) {
  return attrs => (state, dispatch) => {
     const { tr, selection } = state
     const pos = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
     const node = type.createAndFill(attrs, schema.text(attrs.caption));
     dispatch(tr.insert(pos, node));
  }
}

I made two modifications.

  1. Instead of using insertText on the figcaption node, we'll just pass it in as the content to the figure node by using schema.text. The figure content (and attrs) should propagate to the image node and figcaption node that's created since both are children defined in the schema. If you add console.log statements in the toDom methods, you should see that the figure toDom method is called before the other two.
  2. For the same reason, I removed the node.content.content[0].attrs line. Another point: although this may work because the node isn't yet inserted into the document, _directly mutating_ the content of nodes is a bad practice in ProseMirror. Instead, node content should be updated using transaction methods, such as setNodeMarkup. There's this line from the ProseMirror guide that summarizes this:
> Since nodes and fragments are persistent, you should never mutate them. If you have a handle to a document (or node, or fragment) that object will stay the same. Most of the time, you'll use transformations to update documents, and won't have to directly touch the nodes. These also leave a record of the changes, which is necessary when the document is part of an editor state.

@BrianHung
So I tried everything at my disposal, but running your code always returns null for
const node = type.createAndFill(attrs, schema.text(attrs.caption));

And according to the ProseMirror doc,
createAndFill will returns null if no fitting wrapping can be found, return null.
So I wonder why there is no wrapping found!

Hey @neelay92, I've been giving this a try today and this is what I ended up with:

commands({ type, schema }) {
  return attrs => (state, dispatch) => {
    const { tr, selection } = state
    const pos = selection.$cursor ? selection.$cursor.pos : selection.$to.pos

    const node = type.create(null, [
      schema.nodes.image.create({
        alt: attrs.caption,
        src: attrs.src,
      }),
      schema.nodes.figcaption.create(
        null,
        attrs.caption ? schema.text(attrs.caption) : 'default caption'
      ),
    ])

    dispatch(tr.insert(pos, node))
  }
}

I still have no idea why createAndFill returns null or whatever a fitting wrapping should be, but creating the components manually (and therefore being able to pass attributes) works well for me.

So I used code from https://github.com/scrumpy/tiptap/issues/573#issuecomment-570020224 and it worked well.

However what I am still struggling with is this part:

// TO-DO: get image alt if figcaption not found using prosemirror-model

Here's my _Figcaption.js_:

import { Node } from 'tiptap'

export default class Figcaption extends Node {
    get name() {
        return "figcaption"
    }

    get schema() {
        return {
            content: "inline*",
            group: "figure",
            attrs: { attribution: { default: "" } },
            parseDOM: [
                { tag: "img", getAttrs: dom => ({ attribution: dom.dataset.attribution }) },
                { tag: "figcaption" }
            ],
            toDOM: node => [
                "figcaption",
                {
                    contenteditable: node.attrs.attribution ? false : true,
                },
                node.attrs.attribution || "Add attribution here"
            ],
        }
    }
}

But node.attrs.attribution here is undefined even though when I inspect element, the data-attribution="some text" is there. Does anyone know what I am doing wrong?

@portikM Try using hakla's method https://github.com/scrumpy/tiptap/issues/573#issuecomment-616204192 of initializing the figcaption within the figure creation. I would also try expanding the getAttrs function to be more debug-able:
```js
getAttrs: dom => {
console.log(dom); // check if dom.dataset exists
return {attribution: dom.dataset.attribution}
}

@BrianHung that makes sense, yes. But when I try to console.log(dom) it's not even being triggered which means the img node isn't found in the nodes list.

@BrianHung Thank you for the code, it helped me a lot. I ended up using state.tr.replaceSelectionWith(node) instead of insert. With insert() it created extra paragraphs top and bottom of the figure.

@BrianHung Thank you for the code, it helped me a lot. I ended up using state.tr.replaceSelectionWith(node) instead of insert. With insert() it created extra paragraphs top and bottom of the figure.

That didn't seem to solve the issue after you save. The paragraphs top/bottom still seem to add. Did you solve it?

Edit: Was related to the div I wrapped around the toDOM method.

hey can i get a final code?, i still not understand @BrianHung

Hi @BrianHung your implementation of figure with figcaption works great. Thank you.

Thank said, I'm struggling to implement ways to let the user leave figcaption and continue creating new content e.g. new paragraph or new line.

Do you happen to have an idea of how to go about this, pointing in the right direction would be helpful.

@vinlim If there's no selectable content below the figcaption or figure, I would handle it like you're exiting a codeblock: pressing "Enter" or "Shift+Enter" should create a new paragraph below. For codeblocks, this is done using exitCode, which is given here: https://github.com/ProseMirror/prosemirror-commands/blob/master/src/commands.js#L246.

To adapt it to figcaptions, simply replace the if condition that checks you're in a codeblock !$head.parent.type.spec.code with a condition that checks you're within a figcaption !head.parent.type.name == 'figcaption' (or something similar).

You would then use it within figcaption or figure like

  keys({ type }) {
    return {
      'Shift-Enter': exitFigcaption(type),
    }
  }

Edit: Tiptap also has a useful plugin, TrailingNodes, for these circumstances. I would also recommend looking into the prosemirror gapcursor plugin, https://github.com/ProseMirror/prosemirror-gapcursor, which Tiptap optionally includes in the default editor. But if you don't always want a trailing paragraph, use the exitCode method.


@Tukizz The code I originally posted above is the code I'm using. If you don't understand, I would recommend groking through ProseMirror's documentation, https://prosemirror.net/docs/ref/, which tiptap builds upon. Reading through that has been extremely useful in building custom tiptap nodes. Best of luck!

@BrianHung Can you elaborate more on that? I can't get mine to work:

keys({ type }) {
  return {
    'Enter': (state, dispatch) => {
      let {$head, $anchor} = state.selection
      if ($head.parent.type.name !== 'figcaption' || !$head.sameParent($anchor)) return false
      let above = $head.node(-1), after = $head.indexAfter(-1)
      if (!above.canReplaceWith(after, after, type)) return false
      if (dispatch) {
        let pos = $head.after(), tr = state.tr.replaceWith(pos, pos, type.createAndFill())
        tr.setSelection(Selection.near(tr.doc.resolve(pos), 1))
        dispatch(tr.scrollIntoView())
      }
      return true
    },
  }
}

I managed to get everything to work the way I want it when editing the document. However, when I load the stored HTML from database, the attributes and value never reach <img> and <figcaption>, resulting in just a white box in editor where the image should be. I also added a new width attribute to <figure> and that works fine.

This is the HTML snippet for the Figure element I have stored as JSON in Db:

<figure style=\"width: 300px; margin: 0 auto;\">
    <img src=\"https://placekitten.com/600/200\" alt=\"Cat\">
    <figcaption>Zat</figcaption>
</figure>

If I inspect what gets outputted to DOM on load, I get this:

<figure style="width: 300px; margin: 0 auto;">
    <img contenteditable="false">
    <figcaption><br></figcaption>
</figure>

This is the current schema part of Figure.js:

get schema() {
    return {
      content: "image_block figcaption",
      group: "block",
      inline: false,
      attrs: {
        width: { default: null }
      },
      parseDOM: [{
        tag: "figure",
        getAttrs: dom => {
          return {
            width: dom.style.width
          }
        }
      }],
      toDOM(node) {
        let styleString = ''
        if (node.attrs.width) {
          styleString = 'width: ' + node.attrs.width + '; margin: 0 auto;'
        }

        return ["figure", { style: styleString }, 0]
      }
    };
  }

If I remove group: "block", both <img> and <figcaption> get their values just fine, but without the wrapping <figure> element. So for some reason when they are nested inside <figure>, the attrs never reach the subcomponents. It could be that I just somehow messed up my code, but I've been reading and re-reading it several times and comparing it with BulletList and ListItem components, which have a similar dependency with each other, and I just don't see what could cause this.

Has anyone gotten this to work when loading the value dynamically?

What is your image_block code looks like?

This is the entire ImageBlock.js:

import { Node, Plugin } from "tiptap"

export default class ImageBlock extends Node {

  get name() {
    return "image_block"
  }

  get schema() {
    return {
      inline: false,
      group: 'figure',
      attrs: {
        src: { default: null },
        alt: { default: null }
      },
      parseDOM: [{
        tag: "img[src]",
        getAttrs: dom => {
          console.log('src: ' + dom.getAttribute('src'))
          return {
            src: dom.getAttribute("src"),
            alt: dom.getAttribute("alt")
          }
        }
      }],
      toDOM(node) {
        console.log(node.attrs)
        return ["img", node.attrs]
      },
    }
  }

  commands({ type }) {
    return attrs => (state, dispatch) => {
      const { selection } = state
      const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
      const node = type.create(attrs)
      const transaction = state.tr.insert(position, node)
      dispatch(transaction)
    }
  }
}

I don't have the code anymore but I believe I used a div.embed instead of img in tag and for toDom
return ["div", {class:'embed'}, ["img", src, alt]]. I don't remember why but it might be the same issue you're having right now.

That wasn't it @tyuwan , thanks for trying to help though.

I actually did manage to get it to work by storing the document to database as JSON instead of HTML.

Maybe ProseMirror only supports it's own JSON data as input value? Weird that all other elements render fine when loading the editor with HTML input.

Sorry, I misunderstood. Yea, you would have to store it in JSON. We used the JSON to render the HTML(with nicer format) on the fly server-side and cached it.

If it only works with JSON, the parseDOM聽probably doesn鈥檛 work as expected. Did you check that?

Many times. You can see my parseDOM for both Figure and ImageBlock above. I don't see any problem there. The console.log in ImageBlock returns empty string for the src attribute if it's a child of Figure, but works fine if I remove the group: "block" as I said in the comment above.

I also didn't think that the format should matter when storing data. PM should be able to do a 1-to-1 conversion between them. I just don't understand what exactly is the mechanism that passes attributes to node children in ProseMirror to be able to debug this. Also ProseMirror seems fail silently in some cases instead of raising errors, which makes debugging difficult.

Anyway, while it would be easier in my use case to just store HTML as that's the representation I'm using in the end, I can manage storing it in JSON.

It would be great if this could be properly written as an npm module by @BrianHung or someone, as mentioned in #819 . Although if you're busy coding 2.0, the efforts might be in better use there.

@BrianHung ping! I still don't understand how to leave the figcaption to enter a new paragraph.

Or can I treat figure as a single non editable node? Then I could just render the figure using vue

@Zzombiee2361 Sorry for the late response. I looked back at my figure node: using the baseKeymap that comes with prosemirror-commands, pressing 'enter' while in a figcaption _should_ create a new paragraph below the figure. Can you check that you're using the baseKeymap?

Also this is my update figure.ts:

import Node from './Node'
import { Plugin } from 'prosemirror-state'
import type { Node as PMNode, NodeSpec } from "prosemirror-model";

/**
 * https://discuss.prosemirror.net/t/figure-and-editable-caption/462/5
 */
export default class Figure extends Node {

  get name() {
    return "figure";
  }

  get schema(): NodeSpec {
    return {
      attrs: {src: {}, alt: {default: null}, title: {default: null}},
      group: "block",
      content: "inline*",
      parseDOM: [{
        tag: "figure", 
        contentElement: "figcaption",
        getAttrs(dom: HTMLElement) {
          const img = dom.querySelector("img")
          console.log("figure", dom, { src: img.getAttribute("src"), alt: img.getAttribute("alt"), title: img.getAttribute("title") })
          return { src: img.getAttribute("src"), alt: img.getAttribute("alt"), title: img.getAttribute("title") }
        }
      }],
      toDOM(node: PMNode) { 
        let {src, alt, title} = node.attrs; 
        return ["figure", ["img", {src, alt, title}], ["figcaption", 0]] 
      }
    };
  }
}

And updated image:

import Node from "./Node"
import type { EditorView } from "prosemirror-view"
import { Plugin } from "prosemirror-state"
import type { Node as PMNode, NodeSpec } from "prosemirror-model";

/**
 * https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js
 */
export default class Image extends Node {

  get name() {
    return "image"
  }

  get schema(): NodeSpec {
    return {
      attrs: {src: {}, alt: {default: null}, title: {default: null}},
      inline: false,
      group: "block",
      draggable: true,
      parseDOM: [{tag: "img[src]", getAttrs(dom: HTMLImageElement) {
        console.log("image", dom, { src: dom.getAttribute("src"), alt: dom.getAttribute("alt"), title: dom.getAttribute("title") })
        return { src: dom.getAttribute("src"), alt: dom.getAttribute("alt"), title: dom.getAttribute("title") }
      }}],
      toDOM(node: PMNode) { let {src, alt, title} = node.attrs; return ["img", {src, alt, title}] }
    }
  }

  commands({nodeType}) {
    return attrs => (state, dispatch) => {
      const { selection } = state
      const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
      const node = nodeType.create(attrs)
      const transaction = state.tr.insert(position, node)
      dispatch(transaction)
    }
  }
}

Instead of making a figure use a figcaption and image node, I've decided to use only a figure node and an image node. The reason for this is to keep the attributes src, alt, title within the figure, so we don't end up with a figure with an image with no src (an invalid figure).

I'm planning to write some additional keymaps to convert between figures and images as well: for example, if backspace is pressed while caption is already empty, then figures should be converted into an image.


@portikM

Re:

However what I am still struggling with is this part:

TO-DO: get image alt if figcaption not found using prosemirror-model

The above should help. To initialize the content for the figcaption, which is now part of the figure node, we can use the contentElement field.

This is a sketch of what you can do with that:

tag: "figure", 
contentElement: (dom: HTMLElement) => {
  let figcaption = dom.querySelector("figcaption"); // dom is the figure node we parsed
  if (figcaption) return figcaption;
  else {
    let img = dom.querySelector("img"); // assuming image exists
    let figcaption = document.createElement("figcaption")
    figcaption.innerHTML = img.getAttribute("alt")
    return figcaption;
  }
}

If you want to keep the current schema you have, then use getContent instead (rough sketch):

      content: "image figcaption",
      parseDOM: [{
        tag: "figure", 
        getContent(dom: HTMLElement, schema) {

          let img = dom.querySelector("img")
          const imageNode = schema.nodes.image.create({ src: img.getAttribute("src"), alt: img.getAttribute("alt"), title: img.getAttribute("title") })

          let figcaption = dom.querySelector("figcaption)
          let captionContent = figcaption.textContent || img.getAttribute("alt") || img.getAttribute("title);


          const figcaptionNode = schema.nodes.figcaption.create(null, schema.text(captionContent);
          return schema.nodes.figure.create(null, [imageNode, figcaptionNode ]);
        }
      }],

@arokanto @tyuwan pinging you two as well, as this updated figure may help

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dolbex picture dolbex  路  3Comments

connecteev picture connecteev  路  3Comments

glavdir picture glavdir  路  3Comments

leandromatos picture leandromatos  路  4Comments

asseti6 picture asseti6  路  3Comments