Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embedding image in a figure element with a figcaption #573

Closed
svennerberg opened this issue Dec 31, 2019 · 28 comments
Closed

Embedding image in a figure element with a figcaption #573

svennerberg opened this issue Dec 31, 2019 · 28 comments

Comments

@svennerberg
Copy link

svennerberg commented Dec 31, 2019

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!

@BrianHung
Copy link
Contributor

BrianHung commented Jan 1, 2020

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],
    }
  }
}

@svennerberg
Copy link
Author

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

@deepaksisodiya
Copy link

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>
        `
    };
  }
}

@neelay92
Copy link

@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

@BrianHung
Copy link
Contributor

BrianHung commented Mar 29, 2020

@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.

@neelay92
Copy link

neelay92 commented Apr 1, 2020

@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)
		}
	}

@BrianHung
Copy link
Contributor

BrianHung commented Apr 1, 2020

@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.

@neelay92
Copy link

neelay92 commented Apr 6, 2020

@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!

@hakla
Copy link

hakla commented Apr 19, 2020

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.

@portikM
Copy link

portikM commented Apr 30, 2020

So I used code from #573 (comment) 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?

@BrianHung
Copy link
Contributor

BrianHung commented Apr 30, 2020

@portikM Try using hakla's method #573 (comment) of initializing the figcaption within the figure creation. I would also try expanding the getAttrs function to be more debug-able:

getAttrs: dom => {
  console.log(dom); // check if dom.dataset exists
  return {attribution: dom.dataset.attribution}
}

@portikM
Copy link

portikM commented May 1, 2020

@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.

@dokudoki
Copy link

@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.

@enkota
Copy link

enkota commented Jun 20, 2020

@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.

@frama21
Copy link

frama21 commented Sep 15, 2020

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

@vinlim
Copy link

vinlim commented Oct 23, 2020

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.

@hanspagel hanspagel mentioned this issue Oct 25, 2020
6 tasks
@BrianHung
Copy link
Contributor

BrianHung commented Oct 30, 2020

@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!

@Zzombiee2361
Copy link

@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
    },
  }
}

@arokanto
Copy link

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?

@dokudoki
Copy link

What is your image_block code looks like?

@arokanto
Copy link

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)
    }
  }
}

@dokudoki
Copy link

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.

@arokanto
Copy link

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.

@dokudoki
Copy link

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.

@hanspagel
Copy link
Contributor

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

@arokanto
Copy link

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.

@Zzombiee2361
Copy link

@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

@BrianHung
Copy link
Contributor

BrianHung commented Nov 19, 2020

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests