ueberdosis / tiptap

The headless rich text editor framework for web artisans.

Home Page:https://tiptap.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Community Extensions

hanspagel opened this issue · comments

Hi everyone!

I’ve seen a ton of helpful Gists with custom extensions for Tiptap. It’s amazing what you all come up with! 🥰

Unfortunately, we don’t have the capabilities to maintain all of them in the core packages, but it’s sad to see those gems hidden in some Gists. What if you - the community - could easily provide those as separate npm packages?

Advantages of packages

  • New extensions can be added without a need for approval
  • Extensions can be updated and improved by everyone
  • You can come up with extensions we don’t even understand
  • There is more room for “experimental” extensions, that aren’t stable enough for the core package
  • We can have multiple flavors of extensions (e. g. an Image node including the Upload to S3 mechanic)

Proof of Concept

I built a tiny proof of concept for superscript and subscript, see here:
https://github.com/hanspagel/tiptap-extension-superscript
https://github.com/hanspagel/tiptap-extension-subscript

Usage:

$ yarn add tiptap-extension-superscript
import { Superscript } from 'tiptap-extension-superscript'

new Editor({
  extensions: [
    new Superscript(),
  ],
})

Examples of community Gists, code snippets, PRs and ideas

Tiptap v2

Tiptap v1

Not needed with Tiptap v2

Roadmap

I think we’d need to do a few things to make that easier for everyone:

  • Build a proof of concept
  • Ask for feedback
  • Figure out testing
  • Publish an extension boilerplate
  • Write a guide
  • Add a list of community extensions to the README

Your feedback

What do you all think? Would you be up to contribute a community extension?

Feel free to post links to Gists of others you’d love to see published as a package.

I would love to. I am working for a project that involves rich content editor. I am trying to bring each feature one by one. Till now, I was able to get inline math and block level math integrations without any errors. There were few minor issues. But I was able to correct them.

I am more than happy to share it. But I am not writing any unit tests. Is it okay ?

image

like this, there are so many places where the import sizes are huge. This increased just the editor component size in total. Is there any work around for this ?

image

This is an another example for such cases.

Increasing the collection with

  • Custom Link from #783 (comment) (only enhancement on the existing Link currently is that it converts input as well instead of only paste links)
export class CustomLink extends Link {
  get schema() {
    return {
      attrs: {
        href: {
          default: null,
        },
        'data-link-type': {
          default: 'link',
        },
        target: {
          default: null,
        },
        rel: {
          default: null,
        },
        class: {
          default: 'oct-a',
        },
      },
      inclusive: false,
      parseDOM: [
        {
          tag: 'a[href]',
          getAttrs: (dom) => {
            return {
              href: dom.getAttribute('href'),
              target: dom.getAttribute('target'),
              rel: dom.getAttribute('rel'),
              'data-link-type': dom.getAttribute('data-link-type'),
            }
          },
        },
      ],
      toDOM: (node) => {
        return [
          'a',
          {
            ...node.attrs,
            target: '__blank',
            class: 'content-link',
            rel: 'noopener noreferrer nofollow',
          },
          0,
        ]
      },
    }
  }

Found a plugin that supports pasting images under #686 (comment) 👇

https://gist.github.com/slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521
and/or: #508

Leaving this here as a request: heading anchor links extension #662

Resizable image plugin: #740 (comment) (links to this gist)

I created a gist with a TextColor extension file as well as an example with a .vue file to see how to use it: https://gist.github.com/Aymkdn/9f993c5cfe8476f718c4fd2fd7bda1f0

I've ported the TrailingNode extension for TipTap 2: https://gist.github.com/jelleroorda/2a3085f45ef75b9fdd9f76a4444d6bd6

Oh, thanks! Great work! I’ve added it as a (more or less hidden) experiment to the documentation. I think we’ll add this as an official extension:

https://www.tiptap.dev/experiments/trailing-node

One approach for resizable images for v2 #1283

Just doing my reading before trying to create an iframe video embed for v2...

I got video (as) working (as I need to for now) in my project :)

My use case is embedding YouTube, Vimeo or Loom video (the URLs of which have already been created/sanitised outside of tiptap). I pulled some example code out of my project and put it in my example

I'd hacked the helper class for the parent div

return ['div', {class: 'video-wrapper'}, ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]]

Which would be better retrieved from the .configure({HTMLAttributes ... set when instantiating the editor and extensions - but you get the idea. Then set some 'classic' responsive iframe CSS yourself

I guess to make these draggable we'd need to add a handle since all clicks on the iframe belong to the iframe. How does tiptap handle dragging, is it explicitly the node itself, or can we set a handle element somehow? I'm happy with deleting/re-adding in my project tbh.

Oh, thanks! Great work! I’ve added it as a (more or less hidden) experiment to the documentation. I think we’ll add this as an official extension:

https://www.tiptap.dev/experiments/trailing-node

I've updated the gist, by adding a TypeScript variant for trailing node as well.

@hanspagel I have also ported your Subscript extension and your Superscript extension for v2 with TypeScript. Thanks again for those 😄.

@jelleroorda Could you give me a couple of tips on how to implement this Subscript/Superscript extensions to an existing Vue project, where changing the file extensions to .ts is not an option?

@andon94 it should be almost exactly the same, it doesn't require TS. See a quick (untested) example here https://gist.github.com/joevallender/47e957298d7fbf4c41f5a1ba462d1d59

Related to #1304, can someone please help guide the conversation for something as basic as:

For a Vue app, with JS, how can one create a button that simply toggles a class (and repeat accordingly for any desired custom classes).

For example:

[ Uppercase ] [ Large ]

The quick brown fox

Where selecting brown and clicking Uppercase would result in:

The quick <span class="uppercase">brown</span> box

I've tried looking at this example but see it is for TipTap v1. I'm at a bit of a loss because the sup gist is for creating a new mark, the font-family extension is fairly complex and written as a typescript extension.

I think there is just some incredibly basic understanding I am lacking, if someone could just provide some guidance, I would happily try to help contribute to the documentation once I can wrap my head around it.

Perhaps it's one of those things that is so basic/obvious that it can be overlooked by people with more familiarity, but I can imagine it's a very common use-case, to be able to select text and toggle custom classes.

@alancwoo, I've created spanClass extension for that. Let me know if you have a better name for it.

import { Extension } from "@tiptap/core";
import "@tiptap/extension-text-style";
export const SpanClass = Extension.create({
  name: "spanClass",
  defaultOptions: {
    types: ["textStyle"]
  },
  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          spanClass: {
            default: "none",
            renderHTML: (attributes) => {
              if (!attributes.spanClass) {
                return {};
              }
              return {
                class: attributes.spanClass
              };
            },
            parseHTML: (element) => ({
              spanClass: element.classList.value
            })
          }
        }
      }
    ];
  },
  addCommands() {
    return {
      setSpanClass: (spanClass) => ({ chain }) => {
        return chain().setMark("textStyle", { spanClass }).run();
      },
      unsetSpanClass: () => ({ chain }) => {
        return chain()
          .setMark("textStyle", { spanClass: "" })
          .removeEmptyTextStyle()
          .run();
      }
    };
  }
});

and this is how it can be used to add a class to text after wrapping it with a span

this.editor.chain().focus().setSpanClass("uppercase").run();

you can write anything you want instead of uppercase. Also multiple classes are allowed just like we write them in HTML. so something like this should totally work.

this.editor.chain().focus().setSpanClass("uppercase italic bold").run();

this will add the given class to selected text after converting it to a span. Here's a codesandbox of how I am using it https://codesandbox.io/s/wonderful-gauss-j8prn?file=/src/components/Tiptap.vue

@sereneinserenade thank you, you are such a life saver 👏🏽 This is exactly what I needed. I made some slight adjustments to try to simplify things and to make it possible to make multiple buttons of different classes:

Vue
(can set multiple buttons with different classes, with their own active class (this 'active' check seems messy to me, but it works - please let me know if there is a cleaner way about this)):

<button title="Uppercase"
  @click="editor.chain().focus().toggleSpanClass('uppercase').run()"
  :class="{
  'is-active':
    editor.isActive('textStyle') &&
    editor.getAttributes('textStyle').spanClass.includes('uppercase'),
  }"
>
  Uppercase
</button>

// repeat as desired for different classes

SpanClass.js
(reduced to one method)

  addCommands () {
    return {
      toggleSpanClass: (spanClass) => ({ editor, chain }) => {
        console.log(editor)

        if (!editor.isActive('textStyle')) {
          return chain().setMark('textStyle', { spanClass }).run()
        } else {
          let textStyleClasses = editor.getAttributes('textStyle').spanClass.split(' ')

          if ((textStyleClasses).includes(spanClass)) {
            textStyleClasses = textStyleClasses.filter(className => className !== spanClass)
          } else {
            textStyleClasses.push(spanClass)
          }

          if (textStyleClasses.length) {
            return chain().setMark('textStyle', { spanClass: textStyleClasses.join(' ') }).run()
          } else {
            return chain().setMark("textStyle", { spanClass: "" })
              .removeEmptyTextStyle()
              .run()
          }
        }
      },
    }
  }

Thank you again, this is enormously helpful and completely unblocked me.

@sereneinserenade I'm sorry but in the end, it looks like while the data is saved to the database, but the span and its classes are stripped upon re-rendering/loading the editor.

If you look at this fork of your sandbox, I've added an existing uppercase span which is removed on load: https://codesandbox.io/s/tiptap-spanclass-extension-forked-8vh2v?file=/src/components/Tiptap.vue

I imagine it has to do with #495 but am confused how to fix this.

Two features I am looking for in a Rich Text Editor are

  1. Tracked Changes (not necessarily real-time collaboration, but being able to see - Google Docs / MS Word / Apple Pages style - the changes others have made. and a way to 'accept' one, or all changes, and
  2. Commenting (again ala Google Docs / MS Word / Apple Pages)

Commercial RTEs support this kind of thing and it would be amazing to have examples of this in TipTap. Maybe I've just not found the right extensions tho.

How to use anchor ? (#621)

  1. Commenting (again ala Google Docs / MS Word / Apple Pages)

If you create or find the right commenting solution @davesag, it would be awesome to see it here 😃.

A simple extension to support mixed bi-directional text (LTR-RTL), by adding dir="auto" to top nodes.

Two features I am looking for in a Rich Text Editor are

1. Tracked Changes (not necessarily real-time collaboration, but being able to see - Google Docs / MS Word / Apple Pages style - the changes others have made. and a way to 'accept' one, or all changes, and

2. Commenting (again ala Google Docs / MS Word / Apple Pages)

Commercial RTEs support this kind of thing and it would be amazing to have examples of this in TipTap. Maybe I've just not found the right extensions tho.

I really want this functionality, too. I just created an Upwork task to try and find someone to build it. I will open source any code that we create! I'll update here.

I needed a KBD extension, the shortcut is simply <kbd>…</kbd> like you would write on Github.
https://gist.github.com/cadars/78a6c96eac8faf3b11feda3d6ad033e3

(it doesn’t use mergeAttributes because I didn’t need them, but they could be added easily)

Two features I am looking for in a Rich Text Editor are

1. Tracked Changes (not necessarily real-time collaboration, but being able to see - Google Docs / MS Word / Apple Pages style - the changes others have made. and a way to 'accept' one, or all changes, and

2. Commenting (again ala Google Docs / MS Word / Apple Pages)

Commercial RTEs support this kind of thing and it would be amazing to have examples of this in TipTap. Maybe I've just not found the right extensions tho.

I really want this functionality, too. I just created an Upwork task to try and find someone to build it. I will open source any code that we create! I'll update here.

hello Has this function been realized? Can we open source? I also need this function.

So, I've created comment extension, it works for me, will open source soon it's not exactly google doc style, but it works.

Bildschirmaufnahme.2021-10-31.um.22.25.25.mov

@sereneinserenade Thanks for sharing! Just so you know, the link to the repository is a 404. Probably still set to private?

@hanspagel yep, that was it 😅 , now it's OSS and public ⚡

Increasing the collection with

export class CustomLink extends Link {
  get schema() {
    return {
      attrs: {
        href: {
          default: null,
        },
        'data-link-type': {
          default: 'link',
        },
        target: {
          default: null,
        },
        rel: {
          default: null,
        },
        class: {
          default: 'oct-a',
        },
      },
      inclusive: false,
      parseDOM: [
        {
          tag: 'a[href]',
          getAttrs: (dom) => {
            return {
              href: dom.getAttribute('href'),
              target: dom.getAttribute('target'),
              rel: dom.getAttribute('rel'),
              'data-link-type': dom.getAttribute('data-link-type'),
            }
          },
        },
      ],
      toDOM: (node) => {
        return [
          'a',
          {
            ...node.attrs,
            target: '__blank',
            class: 'content-link',
            rel: 'noopener noreferrer nofollow',
          },
          0,
        ]
      },
    }
  }

You are a lovely man! thanks dude!

commented

So I did a thing that converts a-z to greek text and back, for scientific symbols. Had to wrangle a ton of ProseMirror, but this takes care of selection / transformations as well as copy / paste. Took a long time to figure out, hopefully it's useful to someone else.

import {
  Mark,
  markInputRule,
  markPasteRule,
  mergeAttributes,
  textInputRule
} from '@tiptap/core'

import { Plugin, PluginKey } from 'prosemirror-state'




export const Greek = Mark.create({
  name: 'greek',

  addOptions() {
    return {
      HTMLAttributes: {
        class: 'greek'
      },
    }
  },

  parseHTML() {
    return [
      {
        tag: 'o',
      },
    ]
  },

  renderHTML({ HTMLAttributes }) {
    return ['o', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },

  addCommands() {
    let _mark = this
    return {
      useGreek: () => ({state, dispatch, commands}) => {
        let { empty, ranges } = state.selection
        console.log('state:', state, state.selection, state.selection.content())
        const content = state.selection.content()

        if (!empty) {

          // ripped out from toggleMark
          let has = false, tr = state.tr, markType = _mark.type

          console.log('====> type:', markType, _mark)
          for (let i = 0; !has && i < ranges.length; i++) {
            let { $from, $to } = ranges[i]
            has = state.doc.rangeHasMark($from.pos, $to.pos, markType)
          }
          for (let i = 0; i < ranges.length; i++) {
            let { $from, $to } = ranges[i]
            if (has) {
              symbolSlice(content, true, true, true)
              // console.log('un-symbolized: ', normaltext, content, 'selection:', jsonID)
              state.selection.replace(tr, content)
              // tr.insertText(`${trailspace}${normaltext}${trailspace}`, $from.pos, $to.pos)
              tr.removeMark($from.pos, $to.pos, markType)
              // tr.replaceSelection(normalSlice)
            } else {
              // turn selected text into symbols
              const symboltext = symbolSlice(content, true, false, true)
              if (!state.selection.empty) // selection
                state.selection.replace(tr, content)
              tr.addMark($from.pos, $to.pos, markType.create())
              if (state.selection.empty) // necessary for typing in symbols
                tr.insertText(`${symboltext}${trailspace}`, $from.pos, $to.pos)
            }
            // console.log('tr.stateSel:', tr.selection)
            tr.scrollIntoView()
          }

          // toggleMark(cmd.type)(state, dispatch)
          // tr.insertText(`${text}${trailspace}`, state.selection.from, state.selection.to)
          dispatch(tr)
          // return true
          return commands.toggleMark('greek')

        } else {
          // console.log('command...', state, state.tr, 'content:?:?:', )
          // return dispatch(state.tr.insertText("wtf"))
          // return toggleMark(cmd.type)(state, dispatch)
          return commands.toggleMark('greek')
        }
      },
    }
  },

  addKeyboardShortcuts() {
    return {
      'Mod-Shift-g': () => this.editor.commands.toggleGreek(),
    }
  },

  addProseMirrorPlugins() {
    const plugins = []

    plugins.push(
      new Plugin({
        key: new PluginKey('applyGreekText'),
        state: {
          init: (_, state) => {
          },
          // apply: (tr, value, oldState, newState) => {
          apply: (tr) => {
            if (tr.docChanged) {
              // console.log('symbol_node apply:', tr.doc, tr.doc.lastChild, tr.doc.lastChild['textContent'])
              // applies symbol transformation directly on the tr node by ref
              // the reason is b/c we don't want to undo conversion from alpha to greek as a separate transaction
              // const symboltext = symbolSlice(tr.doc.lastChild, false, false, true)
              const symboltext = symbolSlice(tr.doc, false, false, true)
            }
          },
        },
      }),
    )

    return plugins
  }

})

export default Greek



export const symbolSwap = (strdata, isReverse, alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$^*\\") => {
  // swaps all occurrences of a letter in the alpha map to symbols
  // these mimic the symbols font used in Word
  if (strdata === null) {
    return ''
  }

  const symbols = "αβχδεφγηιϕκλμνοπθρστυϖωξψζΑΒΧΔΕΦΓΗΙϑΚΛΜΝΟΠΘΡΣΤΥςΩΞΨΖ!≅#∃⊥∗∴"
  // const alphakeys = Object.keys(alpha)
  // console.log('alpha:', alpha, strdata)

  let output = ""
  if (isReverse) {
    strdata.split('').forEach(str => {
      // console.log('trying...', alpha.indexOf(str))
      if (symbols.indexOf(str) >= 0) { // convert greek to a-z
        // console.log('rev...', str, alpha.charAt(symbols.indexOf(str)))
        output += alpha.charAt(symbols.indexOf(str))
      } else {
        output += str
      }
    })

  } else {
    strdata.split('').forEach(str => {
      // console.log('trying...', alpha.indexOf(str))
      if (alpha.indexOf(str) >= 0) {
        output += symbols.charAt(alpha.indexOf(str))
      } else {
        output += str
      }
    })
  }

  // console.log('!!! output:', output)
  return output
}


export const symbolSlice = (slice, _isSymbol = false, isReverse = false, objReplace = false) => {
  // isSymbol is set to false, and used to look for 'greek' objects when pasting symbols from Word
  // set it to true to convert any kinds of slices, e.g. from a command
  let text = ''

  const dig = (content) => {
    if (Array.isArray(content)) {
      content.map(node => {
        let isSymbol = _isSymbol
        // if(!isReverse)
        text = ''
        if (node['marks'] && node['marks'].length > 0) {
          node['marks'].map(mark => {
            // console.log('mark:', mark)
            if (mark['type']['name'] === "greek")
              isSymbol = true
          })
        }
        // console.log('node :::', isSymbol, node['marks'], node['text'], node)
        if (isSymbol && node && node['text']) {
          // console.log('symbol node:', node, node['text'], isReverse)
          node['text'].split('').map((str) => {
            // console.log('swapping:', str)
            const symbol = symbolSwap(str, isReverse)
            text += symbol
          })
          if (objReplace) {
            node['text'] = text
            // console.log('new text:', text)
          }
          // return node['text'] = text
          return
        }
        else if (node['content']) {
          // console.log('>> digging deeper')
          return dig(node['content'])
        }
        return
      })
    }
    else if (content['content']) {
      // console.log('* digging deeper', content['content'])
      return dig(content['content'])
    }
  }

  // console.log('>--------')
  // console.log(':::: symbol convert:', slice, slice['textContent'])
  if (slice['content']) {
    dig(slice['content'])
  }

  // console.log('returning slice:', slice['content'], text)
  // console.log('<--------')
  return text
}

For those who wanted a google docs like commenting solution, I've implemented in sereneinserenade/tiptap-comment-extension#1.

Try it out: https://tiptap-comment-extension.vercel.app/

here's a demo:

out-comment.mp4

I needed to support <dl> definition lists on paste somehow, so I shamelessly adapted Gitlab’s solution that converts them to plain <ul> lists with classes for styling, maybe this can be useful to someone:

// description-list.js

import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core';

export default Node.create({
  name: 'descriptionList',
  group: 'block list',
  content: 'descriptionItem+',

  parseHTML() {
    return [{ tag: 'dl' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0];
  },

  addInputRules() {
    const inputRegex = /^\s*(<dl>)$/;

    return [wrappingInputRule({ find: inputRegex, type: this.type })];
  },
});
// description-item.js

import { mergeAttributes, Node } from '@tiptap/core';

export default Node.create({
  name: 'descriptionItem',
  content: 'block+',
  defining: true,

  addAttributes() {
    return {
      isTerm: {
        default: true,
        parseHTML: (element) => element.tagName.toLowerCase() === 'dt',
      },
    };
  },

  parseHTML() {
    return [{ tag: 'dt' }, { tag: 'dd' }];
  },

  renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) {
    return [
      'li',
      mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }),
      0,
    ];
  },

  addKeyboardShortcuts() {
    return {
      Enter: () => {
        return this.editor.commands.splitListItem('descriptionItem');
      },
      Tab: () => {
        const { isTerm } = this.editor.getAttributes('descriptionItem');
        if (isTerm)
          return this.editor.commands.updateAttributes('descriptionItem', {
            isTerm: !isTerm,
          });

        return false;
      },
      'Shift-Tab': () => {
        const { isTerm } = this.editor.getAttributes('descriptionItem');
        if (isTerm) return this.editor.commands.liftListItem('descriptionItem');

        return this.editor.commands.updateAttributes('descriptionItem', { isTerm: true });
      },
    };
  },
});

Hello , contributing with another Image Resize extension:

Repo with example: https://github.com/RalphDeving/tiptap-img-resize
Explanation: https://ralphdeving.github.io/blog/post/tiptap-image-resize-vue

Here is a gist to font-size for tiptap 2. It is a direct copy of the official font-family extension.
https://gist.github.com/gregveres/64ec1d8a733feb735b7dd4c46331abae

Here is a gist to set background-color on text for tiptap 2. It is a direct copy of the official color extension.
https://gist.github.com/gregveres/973e8d545ab40dc375b47ebc63f92846

Here is a gist for a line-height extension for tiptap 2. It is a copy of the TextAlign extension as suggested by @hanspagel on this comment for someone else's line-extension pr

https://gist.github.com/gregveres/8757756d56becc2c053c46540cb6b314

Here is some extensions https://github.com/wenerme/wode/tree/main/apps/demo/src/components/TipTapWord/extensions

  • classNames, column-count, margin-{left,right,top,bottom}, line-height, font-size, text-indent, letter-spacing
  • video, indent
  • slash command
  • markdown parse, render

Online demo here

https://wode.vercel.app/tiptap

Wrote a minimal extension for schrolling to the top [Ctrl-Home] or to the bottom [Ctrl-End] of the document.
https://github.com/martinstoeckli/SilentNotes/blob/main/src/ProseMirrorBundle/src/scroll-to-extension.ts

@martinstoeckli that's a nice approach. Alternatively, you can just do this, where most of the things are handled by Tiptap 🙂

  • Focus Start & then scroll to that => editor.chain().focus('start').scrollIntoView().run()
  • Focus End & then scroll to that => editor.chain().focus('end').scrollIntoView().run()

using https://tiptap.dev/api/commands/focus and https://tiptap.dev/api/commands/scroll-into-view

@sereneinserenade Thanks for the tip, I'm aware of this method, but I have the requirement that it must work on a disabled editor, when the editor cannot get the focus. For some reason after calling setContent() with large documents, the page is not always on the top.

Super tiny, simple file paste handler extension:

import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";

const extensionName = "pasteFileHandler";

export type PasteFileHandlerOptions = {
  onFilePasted: (file: File) => boolean;
};

const handleFilePaste = (event: ClipboardEvent, onPasteEvent?: (file: File) => void): void => {
  const { items } = event.clipboardData || event.originalEvent.clipboardData;

  const keys = Object.keys(items);

  keys.some((key) => {
    const item = items[key];

    if (item.kind === "file") {
      const file = item.getAsFile();

      if (onPasteEvent) {
        onPasteEvent(file);
      }

      return true;
    }

    return false;
  });
};

const PasteFileHandler = Extension.create<PasteFileHandlerOptions>({
  name: extensionName,

  addOptions() {
    return {
      onFilePasted: () => false,
    };
  },

  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: new PluginKey(extensionName),
        props: {
          handlePaste: (view, event) => {
            return handleFilePaste(event, this.options.onFilePasted);
          },
        },
      }),
    ];
  },
});

export default PasteFileHandler;

image

I've made an open source project called think, which relies on tiptap to develop a lot of extensions, maybe it can help you.

https://github.com/fantasticit/think/tree/main/packages/client/src/tiptap/core/extensions

Has anybody done table expansion? For example, table border color

Track Changes like Microsoft Office Word. I have implemented this feature, but the code is just in my project. I will publish one day. mark it

image

I've made an open source project called think, which relies on tiptap to develop a lot of extensions, maybe it can help you.

https://github.com/fantasticit/think/tree/main/packages/client/src/tiptap/core/extensions

Very cool. What does the app (think) do?

Track Changes like Microsoft Office Word. I have implemented this feature, but the code is just in my project. I will publish one day. mark it

@chenyuncai
I'm looking for this functionality these days. Please do share:)

image

I've made an open-source project called to think, which relies on tiptap to develop a lot of extensions, maybe it can help you.

https://github.com/fantasticit/think/tree/main/packages/client/src/tiptap/core/extensions

Man, you have very interesting tools, but your documentation is in Chinese :').

image I've made an open-source project called to think, which relies on tiptap to develop a lot of extensions, maybe it can help you. https://github.com/fantasticit/think/tree/main/packages/client/src/tiptap/core/extensions

Man, you have very interesting tools, but your documentation is in Chinese :').

google translate may help. ^_^

image I've made an open-source project called to think, which relies on tiptap to develop a lot of extensions, maybe it can help you. https://github.com/fantasticit/think/tree/main/packages/client/src/tiptap/core/extensions

Man, you have very interesting tools, but your documentation is in Chinese :').

google translate may help. ^_^

I think it is not enough hahahahah

Hey folks, I've been meaning to post this there, but I always end up forgetting about it 😅

This is not an extension per se, but we open-source our Typist editor built on top of Tiptap. It includes a few custom/extended extensions with new and improved features, and it also comes with support for Markdown input/output.

Track Changes like Microsoft Office Word. I have implemented this feature, but the code is just in my project. I will publish one day. mark it

@chenyuncai I'm looking for this functionality these days. Please do share:)

code here

https://github.com/chenyuncai/tiptap-track-change-extension

Is there a way to limit html size? I read the other thread as well, but I think the custom extensions were for tiptap 1.
It's easy to abuse char limit otherwise. What's stoping a user from bolding and italicizing every other char in a 500 long text?

commented

Hello everyone, I created two extensions for tiptap, here they are. Hope that it will help out someone

https://www.npmjs.com/package/@rcode-link/tiptap-drawio
https://www.npmjs.com/package/@rcode-link/tiptap-comments

@chenyuncai Your idea looks amazing, can you share?
Case with, the link seems to be broken

@chenyuncai Your idea looks amazing, can you share? Case with, the link seems to be broken

yes, take a look here https://github.com/chenyuncai/tiptap-track-change-extension

I've released a package called mui-tiptap https://github.com/sjdemartini/mui-tiptap, which adds built-in styling using Material UI, and includes a suite of additional components and extensions. I've been using this code in a production app successfully for months and have incorporated several things that I think add value beyond vanilla Tiptap. For instance:

  • ResizableImage extension for interactively resizing images within the editor with a drag handle
  • TableImproved extension (which resolves some reported Tiptap Table extension issues related to column-resizing, when used in conjunction with the mui-tiptap styles)
  • HeadingWithAnchor extension for dynamic GitHub-like clickable anchor links for every heading that's added (allowing users to share links and jump to specific headings within your rendered editor content)
  • LinkBubbleMenu component so adding and editing links is easy (with a Slack-like link-editing UI)
  • TableBubbleMenu for interactively editing rich text tables (add or delete columns or rows, merge cells, etc.)
  • General-purpose ControlledBubbleMenu for building your own custom menus, solving some shortcomings of the Tiptap BubbleMenu
  • Composable/extendable menu buttons and controls for the standard Tiptap extensions
  • Built-in styles for Tiptap's extensions (text formatting, lists, tables, Google Docs-like collaboration cursors, etc), including support for light and dark mode

Here's a quick demo of some of the UI/functionality (check out the README for a CodeSandbox link and more details):
mui-tiptap demo

The package is still fairly new—I plan to add more functionality soon—but I figured folks here may be interested. I welcome feedback and/or contributions!

Hello, I want to share an extension I created for uploading images with a loading placeholder. I have based it on the ProseMirror example at "https://prosemirror.net/examples/upload/". I hope you can add it and find it useful.

I'm a backend developer, and this is my first npm package. I hope everything is alright configured.

https://github.com/carlosvaldesweb/tiptap-extension-upload-image

Grabacion.de.pantalla.2023-08-25.a.la.s.20.54.49.mov

New Extensions: Hyperlink & HyperMultimedia 🚀

Hello TipTappers!

We're excited to introduce two new extensions to enhance your Tiptap editing experience: @docs.plus/extension-hyperlink and @docs.plus/extension-hypermultimedia.

Hyperlink Extension 🎩🪄

Inspired by Tiptap's extension-link, our hyperlink extension adds a touch of Google Docs magic, streamlining hyperlinking with customizable protocols, auto-linking, and interactive dialog boxes for a user-friendly touch.

NPM | Github | Demo

HyperMultimedia Extension 🎥🎶

Enhance Tiptap with our HyperMultimedia extension, facilitating the embedding of Image, YouTube, Vimeo, SoundCloud, and Twitter posts directly within the editor. Each media type comes with a snazzy modal—use ours or craft your own!

NPM | Github | Demo

We value your feedback. Share your thoughts to help us refine these extensions! 💫💬

Hyperlink Demo | HyperMultimedia Demo

hypermultimedia-github.mp4

hyperlink-demo (g)

commented

Hi, has anyone ever written a hashtag extension (like on Facebook) or a similar extension? I need such an extension but don't know how to customize it

Hi, has anyone ever written a hashtag extension (like on Facebook) or a similar extension? I need such an extension but don't know how to customize it

You can likely extend the mention plugin. The idea is pretty similar right? You trigger with the #, display a set of suggestions, each hashtag is colored in some way with a class.

Hi, I needed to support div tags in the html editor, and I kind of get a solution. It is not perfect, but at least it allows you to add div to your code using editor.chain().insertContent().

I hope it will help to other people, and if you can help me to improve this extensiion would be great.

DivExtension.ts:

import { getNodeContent } from "./extensionUtils";

export interface DivOptions {
  HTMLAttributes: Record<string, any>;
}

export interface DivStyleAttributes {
  class?: string;
  style?: string;
}

// declare module "@tiptap/core" {
//   interface Commands<ReturnType> {
//     div: {
//       /**
//        * Set the div
//        */
//       setDiv: (options?: DivStyleAttributes) => ReturnType;
//     };
//   }
// }

export const Div = Node.create<DivOptions>({
  name: "div",
  group: "block",
  atom: true,
  draggable: true,
  content: "block*",
  selectable: true,
  isolating: false,
  allowGapCursor: true,
  defining: true,

  addAttributes() {
    return {
      class: {
        default: this.options.HTMLAttributes.class,
      },
      style: {
        default: this.options.HTMLAttributes.style,
      },
    };
  },

  parseHTML: () => {
    return [
      {
        tag: "div",
      },
    ];
  },

  renderHTML({ node, HTMLAttributes }) {
    const content = getNodeContent(node);
    return ["div", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ...content];
  },

  parseDOM: [{ tag: "div" }],

  toDOM: () => ["div", 0],

  // addCommands: () => {
  //   return {
  //     setDiv:
  //       (options) =>
  //       ({ tr, dispatch, editor }) => {
  //         const divNode = editor.schema.nodes.div.createChecked(options, null);
  //         if (dispatch) {
  //           const offset = tr.selection.anchor + 1;

  //           tr.replaceSelectionWith(divNode)
  //             .scrollIntoView()
  //             .setSelection(TextSelection.near(tr.doc.resolve(offset)));
  //         }
  //         return true;
  //       },
  //   };
  // },

  addOptions: () => {
    return {
      HTMLAttributes: {},
    };
  },
});

extensionUtils.ts:

import { DOMOutputSpec, Fragment, Node } from "@tiptap/pm/model";

export const getNodeContent = (node: Node | Fragment) => {
  const childNodes: DOMOutputSpec[] = [];
  for (let i = 0; i < node.childCount; i++) {
    const currentChild = node.child(i);
    if (currentChild.type.spec.toDOM) {
      const nodeDOMOutputSpec = currentChild.type.spec.toDOM(currentChild);
      const htmlTag = (nodeDOMOutputSpec as any)[0] as string;
      const content = getNodeContent(currentChild.content);
      childNodes.push([htmlTag, currentChild.attrs, ...content]);
    } else {
      if (currentChild.text) {
        childNodes.push(currentChild.text);
      }
    }
  }
  return childNodes;
};

I am also trying to support icons in the editor, and it seems easy, but for any reason it is not rendering the svg even when it is inserted in the html. Somebody could help me to guess why?

SvgExtension.ts:

import { mergeAttributes, Node } from "@tiptap/core";
import { getNodeContent } from "./extensionUtils";

export interface SvgOptions {
  HTMLAttributes: Record<string, any>;
}

export interface SvgAttributes {
  class?: string;
  style?: string;
  fill?: string;
  height?: string;
  stroke?: string;
  "stroke-width"?: string;
  version?: string;
  viewBox?: string;
  width?: string;
  xmlns?: string;
  "aria-hidden"?: string;
}

export const Svg = Node.create<SvgOptions>({
  name: "svg",
  group: "block",
  // atom: false,
  draggable: true,
  content: "path*",
  selectable: true,
  // isolating: true,
  // allowGapCursor: true,
  // defining: true,

  addAttributes() {
    return {
      class: {
        default: null,
        renderHTML: (attributes) => {
          return attributes.class
            ? {
                style: attributes.class,
              }
            : undefined;
        },
      },
      style: {
        default: this.options.HTMLAttributes.style,
      },
      fill: {
        default: this.options.HTMLAttributes.fill,
      },
      height: {
        default: this.options.HTMLAttributes.height,
      },
      stroke: {
        default: this.options.HTMLAttributes.stroke,
      },
      "stroke-width": {
        default: this.options.HTMLAttributes["stroke-width"],
      },
      "aria-hidden": {
        default: this.options.HTMLAttributes["aria-hidden"],
      },
      version: {
        default: this.options.HTMLAttributes.version,
      },
      viewBox: {
        default: this.options.HTMLAttributes.viewBox,
      },
      width: {
        default: this.options.HTMLAttributes.width,
      },
      xmlns: {
        default: this.options.HTMLAttributes.xmlns,
      },
    };
  },

  parseHTML: () => {
    return [
      {
        tag: "svg",
      },
    ];
  },

  renderHTML({ node, HTMLAttributes }) {
    const content = getNodeContent(node);
    return ["svg", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ...content];
  },

  parseDOM: [{ tag: "svg" }],

  toDOM: () => ["svg", 0],

  addOptions: () => {
    return {
      HTMLAttributes: {},
    };
  },
});

PathExtension.ts:

import { mergeAttributes, Node } from "@tiptap/core";

export interface PathOptions {
  HTMLAttributes: Record<string, any>;
}

export interface PathAttributes {
  d?: string;
  "stroke-linecap"?: string;
  "stroke-linejoin"?: string;
}

export const Path = Node.create<PathOptions>({
  name: "path",
  group: "path",
  draggable: false,
  selectable: false,

  addAttributes() {
    return {
      d: {
        default: this.options.HTMLAttributes.d,
      },
      "stroke-linecap": {
        default: this.options.HTMLAttributes["stroke-linecap"],
      },
      "stroke-linejoin": {
        default: this.options.HTMLAttributes["stroke-linejoin"],
      },
    };
  },

  parseHTML: () => {
    return [
      {
        tag: "path",
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return ["path", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
  },

  parseDOM: [{ tag: "path" }],

  toDOM: () => ["path", 0],

  addOptions: () => {
    return {
      HTMLAttributes: {},
    };
  },
});

I insert this icon in the editor, but for any reason it is not being displayed. If I add this in a plane html file, it works... Can somebody help me? Thank you!

<svg class="h-6 w-6 m-0.5 rounded-sm text-red-700 dark:text-red-700 dark:bg-gray-200" style="color:rgb(185,28,28);height:1.5rem;width:1.5rem;margin:0.125rem;border-radius:0.125rem" fill="currentColor" height="1em" stroke="currentColor" stroke-width="0" version="1.1" viewbox="0 0 16 16" width="1em" xmlns="http://www.w3.org/2000/svg">
<path d="M13.156 9.211c-0.213-0.21-0.686-0.321-1.406-0.331-0.487-0.005-1.073 0.038-1.69 0.124-0.276-0.159-0.561-0.333-0.784-0.542-0.601-0.561-1.103-1.34-1.415-2.197 0.020-0.080 0.038-0.15 0.054-0.222 0 0 0.339-1.923 0.249-2.573-0.012-0.089-0.020-0.115-0.044-0.184l-0.029-0.076c-0.092-0.212-0.273-0.437-0.556-0.425l-0.171-0.005c-0.316 0-0.573 0.161-0.64 0.403-0.205 0.757 0.007 1.889 0.39 3.355l-0.098 0.239c-0.275 0.67-0.619 1.345-0.923 1.94l-0.040 0.077c-0.32 0.626-0.61 1.157-0.873 1.607l-0.271 0.144c-0.020 0.010-0.485 0.257-0.594 0.323-0.926 0.553-1.539 1.18-1.641 1.678-0.032 0.159-0.008 0.362 0.156 0.456l0.263 0.132c0.114 0.057 0.234 0.086 0.357 0.086 0.659 0 1.425-0.821 2.48-2.662 1.218-0.396 2.604-0.726 3.819-0.908 0.926 0.521 2.065 0.883 2.783 0.883 0.128 0 0.238-0.012 0.327-0.036 0.138-0.037 0.254-0.115 0.325-0.222 0.139-0.21 0.168-0.499 0.13-0.795-0.011-0.088-0.081-0.196-0.157-0.271zM3.307 12.72c0.12-0.329 0.596-0.979 1.3-1.556 0.044-0.036 0.153-0.138 0.253-0.233-0.736 1.174-1.229 1.642-1.553 1.788zM7.476 3.12c0.212 0 0.333 0.534 0.343 1.035s-0.107 0.853-0.252 1.113c-0.12-0.385-0.179-0.992-0.179-1.389 0 0-0.009-0.759 0.088-0.759v0zM6.232 9.961c0.148-0.264 0.301-0.543 0.458-0.839 0.383-0.724 0.624-1.29 0.804-1.755 0.358 0.651 0.804 1.205 1.328 1.649 0.065 0.055 0.135 0.111 0.207 0.166-1.066 0.211-1.987 0.467-2.798 0.779v0zM12.952 9.901c-0.065 0.041-0.251 0.064-0.37 0.064-0.386 0-0.864-0.176-1.533-0.464 0.257-0.019 0.493-0.029 0.705-0.029 0.387 0 0.502-0.002 0.88 0.095s0.383 0.293 0.318 0.333v0z"></path><path d="M14.341 3.579c-0.347-0.473-0.831-1.027-1.362-1.558s-1.085-1.015-1.558-1.362c-0.806-0.591-1.197-0.659-1.421-0.659h-7.75c-0.689 0-1.25 0.561-1.25 1.25v13.5c0 0.689 0.561 1.25 1.25 1.25h11.5c0.689 0 1.25-0.561 1.25-1.25v-9.75c0-0.224-0.068-0.615-0.659-1.421v0zM12.271 2.729c0.48 0.48 0.856 0.912 1.134 1.271h-2.406v-2.405c0.359 0.278 0.792 0.654 1.271 1.134v0zM14 14.75c0 0.136-0.114 0.25-0.25 0.25h-11.5c-0.135 0-0.25-0.114-0.25-0.25v-13.5c0-0.135 0.115-0.25 0.25-0.25 0 0 7.749-0 7.75 0v3.5c0 0.276 0.224 0.5 0.5 0.5h3.5v9.75z">
</path>
</svg>

Even though I am still quite new to tiptap/prosemirror, I have managed to develope a working drag handle based on the drag handle from https://github.com/steven-tey/novel. However, unlike with the drag handle in novel, it is possible to drag single list items or whole lists through the editor as expected. Furthermore, it is also possible to select several nodes of different types and drag them as well. I look forward to your feedback and any suggestions for improvement.
I have uploaded the drag handle as an extension to npm and of course opensourced it on github.

I threw together a little extension to add Shiki syntax highlighting to Tiptap. I only did it for myself, but thought it might be useful for someone else.

https://github.com/timomeh/tiptap-extension-code-block-shiki

Hey everyone,

I am using TipTap as a document service for sending bulk emails to clients using prefilled variables. I need to import the documents as HTML and maintain the original styling.

From what I have read it is considered a feature of TipTap that the styles are stripped away and I need to make an extension. I am really struggling with this.

Has anyone had any luck?

Hey everyone! I have been trying forever to get a space between functionality in my Tiptap editor. Basically, I would love to be able to have text aligned left and right on the same line - I want to be able to add dates to my header text and have the dates aligned to the right of the page. My current custom extension achieves this, but the text becomes uneditable after I use it:

import { Node, mergeAttributes } from '@tiptap/core';

export const LeftRightJustifyExtension = Node.create({
  name: 'leftRightJustify',

  group: 'block',

  content: 'inline*',

  parseHTML() {
    return [
      {
        tag: 'div[left-right-justify]',
      },
    ];
  },

  renderHTML({ node }) {
    // Extract text content from the node's children
    const text = node.textContent;

    // Find the middle point or some split logic to divide the text
    const middleIndex = text.indexOf(' | '); // Assuming the split point is ' | '
    const leftText = text.slice(0, middleIndex);
    const rightText = text.slice(middleIndex + 3); // Skipping ' | '

    return ['div', mergeAttributes(node.attrs, { 'left-right-justify': 'true' }),
      ['p', { style: 'display: flex' }, leftText],
      ['p', { style: 'display: flex;' }, rightText],
    ];
  },

  addCommands() {
    return {
      setLeftRightJustify: () => ({ commands }) => {
        return commands.setNode(this.name);
      },
      splitLeftRight: () => ({ state, dispatch }) => {
        const { selection, schema } = state;
        const { from, to } = selection;

        if (from !== to) {
          return false; // Do nothing if there's a text selection
        }

        const node = selection.$from.node();
        const pos = selection.$from.parentOffset;
        const textContent = node.textContent;
        const leftText = textContent.slice(0, pos);
        const rightText = textContent.slice(pos);

        const newNode = schema.nodes.leftRightJustify.create({}, [
          schema.text(leftText),
          schema.text(' | '),
          schema.text(rightText),
        ]);

        const tr = state.tr.replaceWith(from - leftText.length, to + rightText.length, newNode);

        dispatch(tr);
        return true;
      },
    };
  },
});

commented

I'm closing this issue for now as this is super legacy.

You can submit your community extensions here:
https://github.com/ueberdosis/tiptap/discussions/categories/community-extensions

I'd ask everyone else who had questions in this thread to move it over to Discord or Github Discussions as we can actually mark things as "answered" there :)