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