diff --git a/demos/src/Experiments/Youtube/React/index.html b/demos/src/Experiments/Youtube/React/index.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/demos/src/Experiments/Youtube/React/index.jsx b/demos/src/Experiments/Youtube/React/index.jsx
new file mode 100644
index 0000000000..948e6b8b28
--- /dev/null
+++ b/demos/src/Experiments/Youtube/React/index.jsx
@@ -0,0 +1,68 @@
+import './styles.scss'
+
+import Youtube from '@tiptap/extension-youtube'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const MenuBar = ({ editor }) => {
+ const widthRef = React.useRef(null)
+ const heightRef = React.useRef(null)
+
+ React.useEffect(() => {
+ if (widthRef.current && heightRef.current) {
+ widthRef.current.value = 640
+ heightRef.current.value = 480
+ }
+ }, [widthRef.current, heightRef.current])
+
+ if (!editor) {
+ return null
+ }
+
+ const addYoutubeVideo = () => {
+ const url = prompt('Enter YouTube URL')
+
+ editor.commands.setYoutubeVideo({
+ src: url,
+ width: Math.max(320, parseInt(widthRef.current.value, 10)) || 640,
+ height: Math.max(180, parseInt(heightRef.current.value, 10)) || 480,
+ })
+ }
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Youtube,
+ ],
+ content: `
+
Tiptap now supports youtube embeds! Awesome!
+
+
+
+ Try adding your own video to this editor!
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ return (
+
+
+
+
+ )
+}
diff --git a/demos/src/Experiments/Youtube/React/index.spec.js b/demos/src/Experiments/Youtube/React/index.spec.js
new file mode 100644
index 0000000000..8decd9f68a
--- /dev/null
+++ b/demos/src/Experiments/Youtube/React/index.spec.js
@@ -0,0 +1,60 @@
+context('/src/Experiments/Youtube/React/', () => {
+ before(() => {
+ cy.visit('/src/Experiments/Youtube/React/')
+ })
+
+ beforeEach(() => {
+ cy.get('.ProseMirror').type('{selectall}{backspace}')
+ })
+
+ it('adds a video', () => {
+ cy.window().then(win => {
+ cy.stub(win, 'prompt', () => 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share')
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+ })
+ })
+
+ it('adds a video with 320 width and 240 height', () => {
+ cy.window().then(win => {
+ cy.stub(win, 'prompt', () => 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share')
+ cy.get('#width').type('{selectall}{backspace}320')
+ cy.get('#height').type('{selectall}{backspace}240')
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe').should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+ .should('have.css', 'width', '320px')
+ .should('have.css', 'height', '240px')
+ })
+ })
+
+ it('replaces a video', () => {
+ cy.window().then(win => {
+ let runs = 0
+
+ cy.stub(win, 'prompt', () => {
+ runs += 1
+ if (runs === 1) {
+ return 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share'
+ }
+ return 'https://music.youtube.com/watch?v=wRakoMYVHm8'
+ })
+
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .click()
+
+ cy.get('#add').eq(0).click()
+
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/wRakoMYVHm8?controls=0')
+ })
+ })
+})
diff --git a/demos/src/Experiments/Youtube/React/styles.scss b/demos/src/Experiments/Youtube/React/styles.scss
new file mode 100644
index 0000000000..7235e0b989
--- /dev/null
+++ b/demos/src/Experiments/Youtube/React/styles.scss
@@ -0,0 +1,73 @@
+/* Basic editor styles */
+.ProseMirror {
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ }
+
+ code {
+ background-color: rgba(#616161, 0.1);
+ color: #616161;
+ }
+
+ pre {
+ background: #0D0D0D;
+ color: #FFF;
+ font-family: 'JetBrainsMono', monospace;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+
+ code {
+ color: inherit;
+ padding: 0;
+ background: none;
+ font-size: 0.8rem;
+ }
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ hr {
+ margin: 1rem 0;
+ }
+
+ blockquote {
+ padding-left: 1rem;
+ border-left: 2px solid rgba(#0D0D0D, 0.1);
+ }
+
+ iframe {
+ border: 8px solid #000;
+ border-radius: 4px;
+ min-width: 200px;
+ min-height: 200px;
+ display: block;
+ outline: 0px solid transparent;
+ }
+
+ div[data-youtube-video] {
+ cursor: move;
+ padding-right: 24px;
+ }
+
+ .ProseMirror-selectednode iframe {
+ transition: outline 0.15s;
+ outline: 6px solid #ece111;
+ }
+}
diff --git a/demos/src/Experiments/Youtube/Vue/index.html b/demos/src/Experiments/Youtube/Vue/index.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/demos/src/Experiments/Youtube/Vue/index.spec.js b/demos/src/Experiments/Youtube/Vue/index.spec.js
new file mode 100644
index 0000000000..40cb61d796
--- /dev/null
+++ b/demos/src/Experiments/Youtube/Vue/index.spec.js
@@ -0,0 +1,60 @@
+context('/src/Experiments/Youtube/Vue/', () => {
+ before(() => {
+ cy.visit('/src/Experiments/Youtube/Vue/')
+ })
+
+ beforeEach(() => {
+ cy.get('.ProseMirror').type('{selectall}{backspace}')
+ })
+
+ it('adds a video', () => {
+ cy.window().then(win => {
+ cy.stub(win, 'prompt', () => 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share')
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+ })
+ })
+
+ it('adds a video with 320 width and 240 height', () => {
+ cy.window().then(win => {
+ cy.stub(win, 'prompt', () => 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share')
+ cy.get('#width').type('{selectall}{backspace}320')
+ cy.get('#height').type('{selectall}{backspace}240')
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe').should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+ .should('have.css', 'width', '320px')
+ .should('have.css', 'height', '240px')
+ })
+ })
+
+ it('replaces a video', () => {
+ cy.window().then(win => {
+ let runs = 0
+
+ cy.stub(win, 'prompt', () => {
+ runs += 1
+ if (runs === 1) {
+ return 'https://music.youtube.com/watch?v=hBp4dgE7Bho&feature=share'
+ }
+ return 'https://music.youtube.com/watch?v=wRakoMYVHm8'
+ })
+
+ cy.get('#add').eq(0).click()
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/hBp4dgE7Bho?controls=0')
+
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .click()
+
+ cy.get('#add').eq(0).click()
+
+ cy.get('.ProseMirror div[data-youtube-video] iframe')
+ .should('have.length', 1)
+ .should('have.attr', 'src', 'https://www.youtube.com/embed/wRakoMYVHm8?controls=0')
+ })
+ })
+})
diff --git a/demos/src/Experiments/Youtube/Vue/index.vue b/demos/src/Experiments/Youtube/Vue/index.vue
new file mode 100644
index 0000000000..203833fdd6
--- /dev/null
+++ b/demos/src/Experiments/Youtube/Vue/index.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
diff --git a/docs/api/extensions/youtube.md b/docs/api/extensions/youtube.md
new file mode 100644
index 0000000000..4be19a0734
--- /dev/null
+++ b/docs/api/extensions/youtube.md
@@ -0,0 +1,112 @@
+---
+description: Your favorite videos and jams - right in your editor!
+icon: align-left
+---
+
+# YouTube
+[![Version](https://img.shields.io/npm/v/@tiptap/extension-youtube.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-youtube)
+[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-youtube.svg)](https://npmcharts.com/compare/@tiptap/extension-youtube?minimal=true)
+
+This extension adds a new youtube embed node to the editor.
+
+## Installation
+```bash
+npm install @tiptap/extension-youtube
+```
+
+## Settings
+
+### inline
+Controls if the node should be handled inline or as a block.
+
+Default: `false`
+
+```js
+Youtube.configure({
+ inline: false,
+})
+```
+
+### width
+Controls the default width of added videos
+
+Default: `640`
+
+```js
+Youtube.configure({
+ width: 480,
+})
+```
+
+### height
+Controls the default height of added videos
+
+Default: `480`
+
+```js
+Youtube.configure({
+ height: 320,
+})
+```
+
+### controls
+Enables or disables YouTube video controls
+
+Default: `true`
+
+```js
+Youtube.configure({
+ controls: false,
+})
+```
+
+### nocookie
+Enables the nocookie mode for YouTube embeds
+
+Default: `false`
+
+```js
+Youtube.configure({
+ nocookie: true,
+})
+```
+
+### allowFullscreen
+Allows the iframe to be played in fullscreen
+
+Default: `true`
+
+```js
+Youtube.configure({
+ allowFullscreen: false,
+})
+```
+
+
+## Commands
+
+### setYoutubeVideo(options)
+Inserts a YouTube iframe embed at the current position
+
+```js
+editor.commands.setYoutubeVideo({
+ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ width: 640,
+ height: 480,
+})
+```
+
+#### Options
+
+| Option | Description | Optional |
+| ---------------- | ----------------------------------------------------------------------- | -------- |
+| src | The url of the youtube video. Can be a YouTube or YouTube Music link | |
+| width | The embed width (overrides the default option, optional | ✅ |
+| height | The embed height (overrides the default option, optional | ✅ |
+
+
+## Source code
+[packages/extension-youtube/](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-youtube/)
+
+## Usage
+https://embed.tiptap.dev/preview/Extensions/YouTube
diff --git a/packages/extension-youtube/CHANGELOG.md b/packages/extension-youtube/CHANGELOG.md
new file mode 100644
index 0000000000..e4d87c4d45
--- /dev/null
+++ b/packages/extension-youtube/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
diff --git a/packages/extension-youtube/README.md b/packages/extension-youtube/README.md
new file mode 100644
index 0000000000..2d00b68288
--- /dev/null
+++ b/packages/extension-youtube/README.md
@@ -0,0 +1,14 @@
+# @tiptap/extension-youtube
+[![Version](https://img.shields.io/npm/v/@tiptap/extension-youtube.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-youtube)
+[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-youtube.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
+[![License](https://img.shields.io/npm/l/@tiptap/extension-youtube.svg)](https://www.npmjs.com/package/@tiptap/extension-youtube)
+[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
+
+## Introduction
+Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
+
+## Official Documentation
+Documentation can be found on the [tiptap website](https://tiptap.dev).
+
+## License
+Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
diff --git a/packages/extension-youtube/package.json b/packages/extension-youtube/package.json
new file mode 100644
index 0000000000..e1a1baa421
--- /dev/null
+++ b/packages/extension-youtube/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@tiptap/extension-youtube",
+ "description": "a youtube embed extension for tiptap",
+ "version": "2.0.0-beta.1",
+ "homepage": "https://tiptap.dev",
+ "keywords": [
+ "tiptap",
+ "tiptap extension"
+ ],
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "main": "dist/tiptap-extension-video.cjs.js",
+ "umd": "dist/tiptap-extension-video.umd.js",
+ "module": "dist/tiptap-extension-video.esm.js",
+ "types": "dist/packages/extension-video/src/index.d.ts",
+ "files": [
+ "src",
+ "dist"
+ ],
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0-beta.1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ueberdosis/tiptap",
+ "directory": "packages/extension-video"
+ }
+}
diff --git a/packages/extension-youtube/src/index.ts b/packages/extension-youtube/src/index.ts
new file mode 100644
index 0000000000..8b42aff9c1
--- /dev/null
+++ b/packages/extension-youtube/src/index.ts
@@ -0,0 +1,5 @@
+import { Youtube } from './youtube'
+
+export * from './youtube'
+
+export default Youtube
diff --git a/packages/extension-youtube/src/utils.ts b/packages/extension-youtube/src/utils.ts
new file mode 100644
index 0000000000..b381cad2e3
--- /dev/null
+++ b/packages/extension-youtube/src/utils.ts
@@ -0,0 +1,63 @@
+export const isValidYoutubeUrl = (url: string) => {
+ return url.match(/^(https?:\/\/)?(www\.|music\.)?(youtube\.com|youtu\.be)(.+)?$/)
+}
+
+export interface GetEmbedUrlOptions {
+ url: string;
+ controls?: boolean;
+ nocookie?: boolean;
+ startAt?: number;
+}
+
+export const getYoutubeEmbedUrl = (nocookie?: boolean) => {
+ return nocookie ? 'https://www.youtube-nocookie.com/embed/' : 'https://www.youtube.com/embed/'
+}
+
+export const getEmbedURLFromYoutubeURL = (options: GetEmbedUrlOptions) => {
+ const {
+ url,
+ controls,
+ nocookie,
+ startAt,
+ } = options
+
+ // if is already an embed url, return it
+ if (url.includes('/embed/')) {
+ return url
+ }
+
+ // if is a youtu.be url, get the id after the /
+ if (url.includes('youtu.be')) {
+ const id = url.split('/').pop()
+
+ if (!id) {
+ return null
+ }
+ return `${getYoutubeEmbedUrl(nocookie)}${id}`
+ }
+
+ const videoIdRegex = /v=([-\w]+)/gm
+ const matches = videoIdRegex.exec(url)
+
+ if (!matches || !matches[1]) {
+ return null
+ }
+
+ let outputUrl = `${getYoutubeEmbedUrl(nocookie)}${matches[1]}`
+
+ const params = []
+
+ if (!controls) {
+ params.push('controls=0')
+ }
+
+ if (startAt) {
+ params.push(`start=${startAt}`)
+ }
+
+ if (params.length) {
+ outputUrl += `?${params.join('&')}`
+ }
+
+ return outputUrl
+}
diff --git a/packages/extension-youtube/src/youtube.ts b/packages/extension-youtube/src/youtube.ts
new file mode 100644
index 0000000000..524feb01aa
--- /dev/null
+++ b/packages/extension-youtube/src/youtube.ts
@@ -0,0 +1,118 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+
+import { getEmbedURLFromYoutubeURL, isValidYoutubeUrl } from './utils'
+
+export interface YoutubeOptions {
+ inline: boolean;
+ width: number;
+ height: number;
+ controls: boolean;
+ nocookie: boolean;
+ allowFullscreen: boolean;
+ HTMLAttributes: Record,
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ youtube: {
+ /**
+ * Insert a youtube video
+ */
+ setYoutubeVideo: (options: { src: string, width?: number, height?: number, start?: number }) => ReturnType,
+ }
+ }
+}
+
+export const Youtube = Node.create({
+ name: 'youtube',
+
+ addOptions() {
+ return {
+ inline: false,
+ controls: true,
+ HTMLAttributes: {},
+ nocookie: false,
+ allowFullscreen: false,
+ width: 640,
+ height: 480,
+ }
+ },
+
+ inline() {
+ return this.options.inline
+ },
+
+ group() {
+ return this.options.inline ? 'inline' : 'block'
+ },
+
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ },
+ start: {
+ default: 0,
+ },
+ width: {
+ default: this.options.width,
+ },
+ height: {
+ default: this.options.height,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-youtube-video] iframe',
+ },
+ ]
+ },
+
+ addCommands() {
+ return {
+ setYoutubeVideo: options => ({ commands }) => {
+ if (!isValidYoutubeUrl(options.src)) {
+ return false
+ }
+
+ return commands.insertContent({
+ type: this.name,
+ attrs: options,
+ })
+ },
+ }
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const embedUrl = getEmbedURLFromYoutubeURL({
+ url: HTMLAttributes.src,
+ controls: this.options.controls,
+ nocookie: this.options.nocookie,
+ startAt: HTMLAttributes.start || 0,
+ })
+
+ HTMLAttributes.src = embedUrl
+
+ return [
+ 'div',
+ { 'data-youtube-video': '' },
+ [
+ 'iframe',
+ mergeAttributes(
+ this.options.HTMLAttributes,
+ {
+ width: this.options.width,
+ height: this.options.height,
+ allowfullscreen: this.options.allowFullscreen,
+ },
+ HTMLAttributes,
+ ),
+ ],
+ ]
+ },
+})