ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
27.34k stars 2.27k forks source link

Better Clarification in Docs for examples #112

Closed marbuser closed 4 years ago

marbuser commented 5 years ago

Hey there,

I recently discovered tiptap and have enjoyed using it in my projects. When it comes to the basics, it's relatively self explanatory. However, I've recently had a project that is requiring video embeds (specifically youtube).

This isn't that big of a deal since tiptap supports it, but for the life of me I cannot figure out HOW to set it up because the examples aren't really examples since no context is given on how they should be used. The code is just given to you and you are expected to understand it.

I was hoping to get some further clarification on how this works since I've tried the way the code suggests and no luck. Or maybe it did work and I'm just not doing something correctly because no explanation is given. I'm not entirely sure really.

It should also be said that the docs don't really explain if you can/how you can add a button for video embeds.

Hopefully someone can explain this to me and hopefully this can also be marked as a request for some slightly better docs. The code and layout of it itself is good, but the examples just need some added context about how they can be used and such I think?

Thanks.

ilicmarko commented 5 years ago

I completely agree with you. I have also found this plugin recently as I had a new feature request from a client. The plugin it self is really good and provides a simple to use VueJS wrapper, but only for built examples. If you need to add anything custom it gets really hard, because there is no documentation.

I have been following the issue board here and some people failed to implement new request because of lack of documentation. Hopefully we as a community can improve this.

To the problem

As I have put a lot of project hours in this library and got around how it works here is a solution for you.

This whole library is designed around using custom nodes, but as said before there is no documentation how to do it.

What you need is make a custom node. For the sake of example lets embed a Rick Astley video.

I am not sure how the user will input the video but I will guess its a modal.

Here is a video we want to embed:

https://www.youtube.com/watch?v=dQw4w9WgXcQ

This is the embed code given by Youtube

<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

We see that we need the to extract the Youtube video ID from the URL and add it to the embed, like this:

`<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoID}" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`

To extract the video ID we will user regex function found on SO

function youtubeParser(url) {
    const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
    const match = url.match(regExp);
    return (match && match[7].length === 11) ? match[7] : false;
}

This covers all youtube URLs, even short ones youtu.be

Now that we got that sorted out let do the editor thing. As I said before you need to create a new custom node, for example iframe

Here is how it will look:

import { Node } from "tiptap";

export default class Iframe extends Node {
  get name() {
    return "iframe";
  }

  get schema() {
    return {
      attrs: {
        src: {
          default: null
        }
      },
      group: "block",
      selectable: false,
      parseDOM: [
        {
          tag: "iframe",
          getAttrs: dom => ({
            src: dom.getAttribute("src")
          })
        }
      ],
      toDOM: node => [
        "iframe",
        {
          src: `https://www.youtube.com/embed/${node.attrs.src}`,
          frameborder: 0,
          allowfullscreen: "true",
          allow:
            "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
          // You can set the width and height here also
        }
      ]
    };
  }

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

Now when you register this to your editor you will have an iframe command that can insert the required node where the cursor is. Just need to add it to your editor

editor: new Editor({
  extensions: [
    new Bold(),
    new Italic(),
    new Strike(), 
    //  Custom extension
    new Iframe
  ],
  content: "",
})

How to implement this with modal?

This is now simple you just need to send the command to the modal, like this:

<EditorMenuBar :editor="editor">
  <div class="menubar editor-toolbar" slot-scope="{ commands }">
    <button class="menubar-button" @click="showVideoModal(commands.iframe)">
      <Icon name="video"/>
    </button>
  </div>
</EditorMenuBar>

Where your showVideoModal() function will look like this:

showVideoModal(command) {
  this.$refs.videoModal.showModal(command)
}

So you sent the command to your modal, what now?

Add one more function which will detect when user clicks Add.

addCommand(data) {
  if (data.command !== null) {
    data.command(data.data)
  }
}

Then bind this to your modal:

<VideoModal ref="videoModal" @onConfirm="addCommand"/>

You are now done, just implement the modal how you like and when emit the event back. Here is how a modal should look.

<template>
  <div class="modal" v-if="show">
    <input v-model="url" /> <button @click="insertVideo">Add Video</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      url: "",
      command: null,
      show: false
    };
  },
  computed: {
    youtubeID() {
      return this.youtubeParser(this.url);
    }
  },
  methods: {
    youtubeParser(url) {
      const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
      const match = url.match(regExp);
      return match && match[7].length === 11 ? match[7] : false;
    },
    showModal(command) {
      // Add the sent command
      this.command = command;
      this.show = true;
    },
    insertVideo() {
      // Some check can be done here, like if `youtubeID` is not false.
      const data = {
        command: this.command,
        data: {
          src: this.youtubeID
        }
      };

      this.$emit("onConfirm", data);
      this.show = false;
    }
  }
};
</script>

Hope I helped, I will update the answer with a sandbox when I can.

Working sandbox: https://codesandbox.io/s/pk8wk2xpoj

Screenshot:

Working example

philippkuehn commented 5 years ago

@marbuser You are absolutely right! Unfortunately it's not my biggest strength and I do not find the time to improve the docs for now but it's definitely on my list!

marbuser commented 5 years ago

@ilicmarko Thanks! Absolutely fantastic answer! This worked perfectly for me and is the sort of explanations we need on the docs!

@philippkuehn No worries at all, we aren't all perfect. ;) Perhaps for future reference, you could try using something like VuePress (https://vuepress.vuejs.org/) and hosting the actual docs in the repo in a folder called docs or something similar. This way, if someone finds a mistake or wants to add some extra documentation, they can just do a PR on the relevant docpage. :)

Neophen commented 5 years ago

yes please at least allow the users to add PR for improving documentation. I'm having difficulty just trying to reproduce the examples in a simple vue app without having to write out the whole css.

philippkuehn commented 5 years ago

@Neophen feel free to open a PR!

svennerberg commented 5 years ago

Thanks a lot @ilicmarko for the explanation. It works well! However I would like to nest the iframe in a div in order to make it work in a responsive way (kind of like this https://benmarshall.me/responsive-iframes/).

What do you think is the best way to embed the iframe inside a div?

svennerberg commented 5 years ago

After some trial and error I actually got this to work, but I have no idea if this is a good way to do it. I modified the toDOM part of the schema method like so:

get schema() {
  return {
    attrs: {
      src: {
        default: null
      }
    },
    group: 'block',
    selectable: false,
    parseDOM: [
      {
        tag: 'iframe',
        getAttrs: dom => ({
          src: dom.getAttribute('src')
        })
      }
    ],
    toDOM: node => [
        'div',
        {
          class: 'video'
        },
        [
          'iframe',
          {
            src: node.attrs.src,
            frameborder: 0,
            allowfullscreen: 'true',
            width: '100%',
            allow:
              'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
            // You can set the width and height here also
          }
        ]
      ]
  };
}

I would be interested to know if this is the right way to do it or if there's a better way.

ilicmarko commented 5 years ago

@svennerberg Yes this is ok. You just change the DOM structure (toDOM).

techouse commented 5 years ago

That piece of info right there helped me out a lot when I was trying to make my own extension for the <picture> node:

import {Image} from "tiptap-extensions"

export default class Picture extends Image {
    get name() {
        return "picture"
    }

    get schema() {
        return {
            inline:    true,
            attrs:     {
                src:       {
                    default: null
                },
                alt:       {
                    default: null,
                },
                title:     {
                    default: null,
                },
                "data-id": {
                    default: null
                },
                sources:   {
                    default: []
                }
            },
            group:     "inline",
            draggable: true,
            parseDOM:  [
                {
                    tag:      "picture",
                    getAttrs: dom => ({
                        src:       dom.getElementsByTagName("img")[0].getAttribute("src"),
                        title:     dom.getElementsByTagName("img")[0].getAttribute("title"),
                        alt:       dom.getElementsByTagName("img")[0].getAttribute("alt"),
                        "data-id": dom.dataset["id"],
                        sources:   [...dom.getElementsByTagName("source")].map(source => {
                            return {
                                media:       source.getAttribute("media"),
                                srcset:      source.getAttribute("srcset")
                            }
                        })
                    }),
                },
            ],
            toDOM:     node => [
                "picture",
                {"data-id": node.attrs["data-id"]},
                ...node.attrs.sources.map(source => ["source", source]),
                [
                    "img",
                    {
                        src:       node.attrs.src,
                        title:     node.attrs.title,
                        alt:       node.attrs.alt
                    }
                ]
            ],
        }
    }
}

Thanx 💝

bswank commented 5 years ago

@ilicmarko Your example was wonderful. Thank you! I was wondering if you could show me how you would adjust your extension from being an iFrame to a Vue component. In the docs, there’s a Vue component, but when I use it there is no command() method so I have no idea how to actually call the example.

ninest commented 5 years ago

@bswank

there is no command() method so I have no idea how to actually call the example

I'm having this problem too. Did you manage to find a solution?

Cryde commented 5 years ago

Hi ! I think there is a small error in this part (I think that @svennerberg saw that) :

 parseDOM: [
        {
          tag: "iframe",
          getAttrs: dom => ({
            src: dom.getAttribute("src")
          })
        }
 ],

I guess this part is call when we have to render the editor ? (like when edit)

It will take the src with this for instance https://www.youtube.com/watch?v=yoWDxUVIHPU

And give it back to :

toDOM: node => [
"iframe",
{
  src: `https://www.youtube.com/embed/${node.attrs.src}`, // <- here
  frameborder: 0,
  allowfullscreen: "true",
  allow: "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
}
]

It will generate an url like : https://www.youtube.com/embed/https://www.youtube.com/watch?v=yoWDxUVIHPU BUT we want : https://www.youtube.com/embed/yoWDxUVIHPU

So we need to change toDom:

toDOM: node => [
"iframe",
{
  src: node.attrs.src`,
  frameborder: 0,
  allowfullscreen: "true",
  allow: "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
}
]

But also :

insertVideo() {
  // Some check can be done here, like if `youtubeID` is not false.
  const data = {
    command: this.command,
    data: {
      src: `https://www.youtube.com/embed/${this.youtubeID}` // <- changed
    }
  };

  this.$emit("onConfirm", data);
  this.show = false;
}
rasliche commented 4 years ago

So would anyone have an idea on how to render the iframe example like the docs have it? Where there is an input field below the video that you can alter when the editor is able to be edited?

The modal example given above seems.... awesome? But also a little overkill, right? Shouldn't I be able to just conditionally render an input field to input the source for images/videos/embeds like the example? Then when you're not editing it, the input shoudl just be hidden (v-if).

Is the get view() basically a renderless component? It's returning an object, so are those the component options? TipTap's example has a template option so that would make me think you could have a render() function instead, but I don't know how that relates to the toDom function in the schema above it in the example... : (

ninest commented 4 years ago

@rasliche I also thought the example was a little overkill and I wasn't able to modify to insert other elements. I'm hoping for better docs too

rasliche commented 4 years ago

SOLVED Had to listen for the paste event and stop propagation, like in the example of course.

I'm having an issue being able to copy/paste into the rendered component in TipTap. I mostly got my version of an iframe working, but for some reason in the editor I can't paste into the that gets rendered. If I Cmd+V with my cursor in the input, the URL I'm trying to paste appears above or below the iframe "component" i'm trying to make.

Any thoughts?

import { Node } from 'tiptap'

export default class Iframe extends Node {
  get name() {
    return 'iframe'
  }

  get schema() {
    return {
      attrs: {
        // These have defaults. Here's the attribute spec:
        // https://prosemirror.net/docs/ref/#model.AttributeSpec
        frameborder: {
          default: 0
        },
        allowfullscreen: {
          default: 'true'
        },
        allow: {
          default: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
        },
        width: {
          default: '560'
        },
        height: {
          default: '315'
        },
        src: {
          default: 'https://youtube.com/embed/IHv0nVxnycw'
        }
      },
      group: 'block',
      selectable: false,
      parseDOM: [
        {
          tag: 'iframe[src]',
          getAttrs: dom => ({
            src: dom.getAttribute('src')
          })
        }
      ],
      toDOM: node => ['iframe', node.attrs],
    }
  }

  get view() {
    // This is rendered as a Vue component and so it has
    // the same interface as a renderless component.
    return {
      // Give it a name to work with Vue dev tools
      name: 'YoutubeEmbed',
      // Accept these props from... something? tiptap?
      props: ['node', 'updateAttrs', 'view'],
      computed: {
        src: {
          get() {
            return this.node.attrs.src
          },
          // Can't mutate src directly so tiptap
          // provides the updateAttrs function
          set(src) {
            this.updateAttrs({
              src,
            })
          },
        },
      },
      // Render Function
      // Because the version of vue doesn't ship with
      // the template compiler
      render: function(createElement) {
        // If the view is editable show an input field
        if (this.view.editable) {
          // console.log(this.view)
          // console.log(this.node)
          // console.log(`Computed src: ${this.src}`)
          // Wrap it all in a div
          return createElement('div', {
              class: 'text-center'
            },
            [
            // https://vuejs.org/v2/guide/render-function.html#Complete-Example
            createElement('iframe', {
              class: 'mx-auto',
              attrs: {
                ...this.node.attrs
              }
            }),
            createElement('label', {
              class: 'block'
              },
              [
                'Video Embed URL',
                createElement('input', {
                  class: 'ml-2 w-auto',
                  domProps: {
                    value: this.src
                  },
                  on: {
                    input: event => {
                      this.src = event.target.value
                      // console.log(event.target.value)
                    },
                    paste: event => {
                      event.stopPropagation()
                      this.src = event.target.value
                    }
                  }
                })
              ])
          ])
        } else {
          // Wrap it all in a div
          return createElement('div', [
            // https://vuejs.org/v2/guide/render-function.html#Complete-Example
            createElement('iframe', {
              attrs: {
                ...this.node.attrs
              }
            }),
          ])
        }
      }
    }
  }

  // type in the commands function is destructured from
  // 
  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)
    }
  }
}
hanspagel commented 4 years ago

Closing this here. We started to work on the documentation for tiptap 2. 🙃