snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
1527
packages/extension-youtube/CHANGELOG.md
Normal file
1527
packages/extension-youtube/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-youtube/README.md
Normal file
18
packages/extension-youtube/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-youtube
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-youtube)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-youtube)
|
||||
[](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).
|
||||
50
packages/extension-youtube/package.json
Normal file
50
packages/extension-youtube/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@tiptap/extension-youtube",
|
||||
"version": "3.30.3",
|
||||
"description": "a youtube embed extension for tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-youtube"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
5
packages/extension-youtube/src/index.ts
Normal file
5
packages/extension-youtube/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Youtube } from './youtube.js'
|
||||
|
||||
export * from './youtube.js'
|
||||
|
||||
export default Youtube
|
||||
163
packages/extension-youtube/src/utils.test.ts
Normal file
163
packages/extension-youtube/src/utils.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import {
|
||||
getAttributesFromYoutubeEmbedUrl,
|
||||
getEmbedUrlFromYoutubeUrl,
|
||||
isValidYoutubeUrl,
|
||||
} from './utils.js'
|
||||
|
||||
describe('YouTube Live URL handling', () => {
|
||||
it('generates correct embed URL for a YouTube Live URL', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
|
||||
controls: true,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for a YouTube Live URL without www prefix', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://youtube.com/live/EkRHhOCdZjw',
|
||||
controls: true,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for a YouTube Live URL with multiple parameters', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
rel: 0,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1&controls=0&rel=0')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for a YouTube Live URL with nocookie option', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
|
||||
nocookie: true,
|
||||
controls: true,
|
||||
rel: 1,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube-nocookie.com/embed/EkRHhOCdZjw?rel=1')
|
||||
})
|
||||
|
||||
it('keeps a live URL with a query string on the video id', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/live/EkRHhOCdZjw?feature=share',
|
||||
controls: true,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('YouTube path-like near misses', () => {
|
||||
const nearMisses = [
|
||||
'https://www.youtube.com/notlive/EkRHhOCdZjw',
|
||||
'https://www.youtube.com/notshorts/EkRHhOCdZjw',
|
||||
'https://www.youtube.com/watch?redirect=live/EkRHhOCdZjw',
|
||||
]
|
||||
|
||||
nearMisses.forEach(url => {
|
||||
it(`does not embed a video id for ${url}`, () => {
|
||||
expect(getEmbedUrlFromYoutubeUrl({ url, controls: true })).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('YouTube Shorts URL handling', () => {
|
||||
it('generates correct embed URL for YouTube Shorts with rel parameter', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
|
||||
controls: true,
|
||||
rel: 1,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?rel=1')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for YouTube Shorts without www prefix', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://youtube.com/shorts/EkRHhOCdZjw',
|
||||
controls: true,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for YouTube Shorts with multiple parameters', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
rel: 0,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1&controls=0&rel=0')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for YouTube Shorts with nocookie option', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
|
||||
nocookie: true,
|
||||
controls: true,
|
||||
rel: 1,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube-nocookie.com/embed/EkRHhOCdZjw?rel=1')
|
||||
})
|
||||
|
||||
it('generates correct embed URL for YouTube Shorts without extra parameters', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
|
||||
controls: true,
|
||||
})
|
||||
|
||||
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('YouTube Playlist URL handling', () => {
|
||||
it('generates correct embed URL for playlist with additional parameters using & separator', () => {
|
||||
const result = getEmbedUrlFromYoutubeUrl({
|
||||
url: 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
|
||||
controls: true,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
// Playlist URLs use ?list= in base URL, so additional params should use &
|
||||
expect(result).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/videoseries?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf&autoplay=1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://www.youtube.com/embed/',
|
||||
'https://www.youtube.com/embed/videoseries',
|
||||
'https://example.com/embed/dQw4w9WgXcQ',
|
||||
])('returns null for unsupported youtube embed urls: %s', embedUrl => {
|
||||
expect(getAttributesFromYoutubeEmbedUrl(embedUrl)).toBeNull()
|
||||
})
|
||||
|
||||
describe('missing URLs', () => {
|
||||
it('accepts a well-formed youtube url', () => {
|
||||
expect(isValidYoutubeUrl('https://www.youtube.com/watch?v=EkRHhOCdZjw')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns null for a missing url instead of throwing', () => {
|
||||
expect(isValidYoutubeUrl(null)).toBe(null)
|
||||
})
|
||||
|
||||
it('returns null from getEmbedUrlFromYoutubeUrl when the url is missing', () => {
|
||||
expect(getEmbedUrlFromYoutubeUrl({ url: null as unknown as string })).toBe(null)
|
||||
})
|
||||
})
|
||||
234
packages/extension-youtube/src/utils.ts
Normal file
234
packages/extension-youtube/src/utils.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
export const YOUTUBE_REGEX =
|
||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu\.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/
|
||||
export const YOUTUBE_REGEX_GLOBAL =
|
||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu\.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/g
|
||||
|
||||
/**
|
||||
* Checks whether a url is a youtube url that the extension can embed.
|
||||
*
|
||||
* The `src` attribute of a youtube node is declared with `default: null`, so a node
|
||||
* parsed from an iframe without a `src` attribute legitimately carries no url. Guard
|
||||
* against that here instead of letting the caller dereference it.
|
||||
*/
|
||||
export const isValidYoutubeUrl = (url: string | null | undefined) => {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return url.match(YOUTUBE_REGEX)
|
||||
}
|
||||
|
||||
export interface GetEmbedUrlOptions {
|
||||
url: string
|
||||
allowFullscreen?: boolean
|
||||
autoplay?: boolean
|
||||
ccLanguage?: string
|
||||
ccLoadPolicy?: boolean
|
||||
controls?: boolean
|
||||
disableKBcontrols?: boolean
|
||||
enableIFrameApi?: boolean
|
||||
endTime?: number
|
||||
interfaceLanguage?: string
|
||||
ivLoadPolicy?: number
|
||||
loop?: boolean
|
||||
modestBranding?: boolean
|
||||
nocookie?: boolean
|
||||
origin?: string
|
||||
playlist?: string
|
||||
progressBarColor?: string
|
||||
startAt?: number
|
||||
rel?: number
|
||||
}
|
||||
|
||||
export interface YoutubeEmbedAttributes {
|
||||
src: string
|
||||
start?: number
|
||||
}
|
||||
|
||||
export const getYoutubeEmbedUrl = (nocookie?: boolean, isPlaylist?: boolean) => {
|
||||
if (isPlaylist) {
|
||||
return 'https://www.youtube-nocookie.com/embed/videoseries?list='
|
||||
}
|
||||
return nocookie ? 'https://www.youtube-nocookie.com/embed/' : 'https://www.youtube.com/embed/'
|
||||
}
|
||||
|
||||
export const getEmbedUrlFromYoutubeUrl = (options: GetEmbedUrlOptions) => {
|
||||
const {
|
||||
url,
|
||||
allowFullscreen,
|
||||
autoplay,
|
||||
ccLanguage,
|
||||
ccLoadPolicy,
|
||||
controls,
|
||||
disableKBcontrols,
|
||||
enableIFrameApi,
|
||||
endTime,
|
||||
interfaceLanguage,
|
||||
ivLoadPolicy,
|
||||
loop,
|
||||
modestBranding,
|
||||
nocookie,
|
||||
origin,
|
||||
playlist,
|
||||
progressBarColor,
|
||||
startAt,
|
||||
rel,
|
||||
} = options
|
||||
|
||||
if (!isValidYoutubeUrl(url)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 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|list)=|\/(?:shorts|live)\/)([-\w]+)/gm
|
||||
const matches = videoIdRegex.exec(url)
|
||||
|
||||
if (!matches || !matches[2]) {
|
||||
return null
|
||||
}
|
||||
|
||||
let outputUrl = `${getYoutubeEmbedUrl(nocookie, matches[1] === 'list')}${matches[2]}`
|
||||
|
||||
const params = []
|
||||
|
||||
if (allowFullscreen === false) {
|
||||
params.push('fs=0')
|
||||
}
|
||||
|
||||
if (autoplay) {
|
||||
params.push('autoplay=1')
|
||||
}
|
||||
|
||||
if (ccLanguage) {
|
||||
params.push(`cc_lang_pref=${ccLanguage}`)
|
||||
}
|
||||
|
||||
if (ccLoadPolicy) {
|
||||
params.push('cc_load_policy=1')
|
||||
}
|
||||
|
||||
if (!controls) {
|
||||
params.push('controls=0')
|
||||
}
|
||||
|
||||
if (disableKBcontrols) {
|
||||
params.push('disablekb=1')
|
||||
}
|
||||
|
||||
if (enableIFrameApi) {
|
||||
params.push('enablejsapi=1')
|
||||
}
|
||||
|
||||
if (endTime) {
|
||||
params.push(`end=${endTime}`)
|
||||
}
|
||||
|
||||
if (interfaceLanguage) {
|
||||
params.push(`hl=${interfaceLanguage}`)
|
||||
}
|
||||
|
||||
if (ivLoadPolicy) {
|
||||
params.push(`iv_load_policy=${ivLoadPolicy}`)
|
||||
}
|
||||
|
||||
if (loop) {
|
||||
params.push('loop=1')
|
||||
}
|
||||
|
||||
if (modestBranding) {
|
||||
params.push('modestbranding=1')
|
||||
}
|
||||
|
||||
if (origin) {
|
||||
params.push(`origin=${origin}`)
|
||||
}
|
||||
|
||||
if (playlist) {
|
||||
params.push(`playlist=${playlist}`)
|
||||
}
|
||||
|
||||
if (startAt) {
|
||||
params.push(`start=${startAt}`)
|
||||
}
|
||||
|
||||
if (progressBarColor) {
|
||||
params.push(`color=${progressBarColor}`)
|
||||
}
|
||||
|
||||
if (rel !== undefined) {
|
||||
params.push(`rel=${rel}`)
|
||||
}
|
||||
|
||||
if (params.length) {
|
||||
outputUrl += `${matches[1] === 'list' ? '&' : '?'}${params.join('&')}`
|
||||
}
|
||||
|
||||
return outputUrl
|
||||
}
|
||||
|
||||
export const getAttributesFromYoutubeEmbedUrl = (url: string): YoutubeEmbedAttributes | null => {
|
||||
let parsedUrl: URL
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname.replace(/^www\./, '')
|
||||
|
||||
if (hostname !== 'youtube.com' && hostname !== 'youtube-nocookie.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
let src: string | null = null
|
||||
|
||||
if (parsedUrl.pathname === '/embed/videoseries') {
|
||||
const list = parsedUrl.searchParams.get('list')
|
||||
|
||||
if (!list) {
|
||||
return null
|
||||
}
|
||||
|
||||
src = `https://www.youtube.com/playlist?list=${list}`
|
||||
} else {
|
||||
const matches = parsedUrl.pathname.match(/^\/embed\/([\w-]+)$/)
|
||||
|
||||
if (!matches?.[1]) {
|
||||
return null
|
||||
}
|
||||
|
||||
src = `https://www.youtube.com/watch?v=${matches[1]}`
|
||||
}
|
||||
|
||||
if (!isValidYoutubeUrl(src)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attributes: YoutubeEmbedAttributes = { src }
|
||||
const start = parsedUrl.searchParams.get('start')
|
||||
|
||||
if (start) {
|
||||
const parsedStart = Number.parseInt(start, 10)
|
||||
|
||||
if (!Number.isNaN(parsedStart)) {
|
||||
attributes.start = parsedStart
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
399
packages/extension-youtube/src/youtube.ts
Normal file
399
packages/extension-youtube/src/youtube.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { createAtomBlockMarkdownSpec, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
|
||||
|
||||
import {
|
||||
getAttributesFromYoutubeEmbedUrl,
|
||||
getEmbedUrlFromYoutubeUrl,
|
||||
isValidYoutubeUrl,
|
||||
YOUTUBE_REGEX_GLOBAL,
|
||||
} from './utils.js'
|
||||
|
||||
const getParsedDimension = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
if (trimmedValue === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsedValue = Number(trimmedValue)
|
||||
|
||||
return Number.isNaN(parsedValue) ? trimmedValue : parsedValue
|
||||
}
|
||||
|
||||
const getParsedYoutubeAttributes = (element: HTMLElement) => {
|
||||
const src = element.getAttribute('src')
|
||||
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
||||
const embedAttributes = getAttributesFromYoutubeEmbedUrl(src)
|
||||
|
||||
if (embedAttributes) {
|
||||
return embedAttributes
|
||||
}
|
||||
|
||||
return {
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
export type { GetEmbedUrlOptions } from './utils.js'
|
||||
export {
|
||||
getAttributesFromYoutubeEmbedUrl,
|
||||
getEmbedUrlFromYoutubeUrl,
|
||||
isValidYoutubeUrl,
|
||||
} from './utils.js'
|
||||
|
||||
export interface YoutubeOptions {
|
||||
/**
|
||||
* Controls if the paste handler for youtube videos should be added.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
addPasteHandler: boolean
|
||||
|
||||
/**
|
||||
* Controls if the youtube video should be allowed to go fullscreen.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
allowFullscreen: boolean
|
||||
|
||||
/**
|
||||
* Controls if the youtube video should autoplay.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
autoplay: boolean
|
||||
|
||||
/**
|
||||
* The language of the captions shown in the youtube video.
|
||||
* @default undefined
|
||||
* @example 'en'
|
||||
*/
|
||||
ccLanguage?: string
|
||||
|
||||
/**
|
||||
* Controls if the captions should be shown in the youtube video.
|
||||
* @default undefined
|
||||
* @example true
|
||||
*/
|
||||
ccLoadPolicy?: boolean
|
||||
|
||||
/**
|
||||
* Controls if the controls should be shown in the youtube video.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
controls: boolean
|
||||
|
||||
/**
|
||||
* Controls if the keyboard controls should be disabled in the youtube video.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
disableKBcontrols: boolean
|
||||
|
||||
/**
|
||||
* Controls if the iframe api should be enabled in the youtube video.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
enableIFrameApi: boolean
|
||||
|
||||
/**
|
||||
* The end time of the youtube video.
|
||||
* @default 0
|
||||
* @example 120
|
||||
*/
|
||||
endTime: number
|
||||
|
||||
/**
|
||||
* The height of the youtube video.
|
||||
* @default 480
|
||||
* @example 720
|
||||
* @example '100%'
|
||||
*/
|
||||
height: number | string
|
||||
|
||||
/**
|
||||
* The language of the youtube video.
|
||||
* @default undefined
|
||||
* @example 'en'
|
||||
*/
|
||||
interfaceLanguage?: string
|
||||
|
||||
/**
|
||||
* Controls if the video annotations should be shown in the youtube video.
|
||||
* @default 0
|
||||
* @example 1
|
||||
*/
|
||||
ivLoadPolicy: number
|
||||
|
||||
/**
|
||||
* Controls if the youtube video should loop.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
loop: boolean
|
||||
|
||||
/**
|
||||
* Controls if the youtube video should show a small youtube logo.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
modestBranding: boolean
|
||||
|
||||
/**
|
||||
* The HTML attributes for a youtube video node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* Controls if the youtube node should be inline or not.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
inline: boolean
|
||||
|
||||
/**
|
||||
* Controls if the youtube video should be loaded from youtube-nocookie.com.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
nocookie: boolean
|
||||
|
||||
/**
|
||||
* The origin of the youtube video.
|
||||
* @default ''
|
||||
* @example 'https://tiptap.dev'
|
||||
*/
|
||||
origin: string
|
||||
|
||||
/**
|
||||
* The playlist of the youtube video.
|
||||
* @default ''
|
||||
* @example 'PLQg6GaokU5CwiVmsZ0dZm6VeIg0V5z1tK'
|
||||
*/
|
||||
playlist: string
|
||||
|
||||
/**
|
||||
* The color of the youtube video progress bar.
|
||||
* @default undefined
|
||||
* @example 'red'
|
||||
*/
|
||||
progressBarColor?: string
|
||||
|
||||
/**
|
||||
* The width of the youtube video.
|
||||
* @default 640
|
||||
* @example 1280
|
||||
* @example '100%'
|
||||
*/
|
||||
width: number | string
|
||||
|
||||
/**
|
||||
* Controls if the related youtube videos at the end are from the same channel.
|
||||
* @default 1
|
||||
* @example 0
|
||||
*/
|
||||
rel: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for setting a youtube video.
|
||||
*/
|
||||
type SetYoutubeVideoOptions = {
|
||||
src: string
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
start?: number
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
youtube: {
|
||||
/**
|
||||
* Insert a youtube video
|
||||
* @param options The youtube video attributes
|
||||
* @example editor.commands.setYoutubeVideo({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
|
||||
*/
|
||||
setYoutubeVideo: (options: SetYoutubeVideoOptions) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension adds support for youtube videos.
|
||||
* @see https://www.tiptap.dev/api/nodes/youtube
|
||||
*/
|
||||
export const Youtube = Node.create<YoutubeOptions>({
|
||||
name: 'youtube',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
addPasteHandler: true,
|
||||
allowFullscreen: true,
|
||||
autoplay: false,
|
||||
ccLanguage: undefined,
|
||||
ccLoadPolicy: undefined,
|
||||
controls: true,
|
||||
disableKBcontrols: false,
|
||||
enableIFrameApi: false,
|
||||
endTime: 0,
|
||||
height: 480,
|
||||
interfaceLanguage: undefined,
|
||||
ivLoadPolicy: 0,
|
||||
loop: false,
|
||||
modestBranding: false,
|
||||
HTMLAttributes: {},
|
||||
inline: false,
|
||||
nocookie: false,
|
||||
origin: '',
|
||||
playlist: '',
|
||||
progressBarColor: undefined,
|
||||
width: 640,
|
||||
rel: 1,
|
||||
}
|
||||
},
|
||||
|
||||
inline() {
|
||||
return this.options.inline
|
||||
},
|
||||
|
||||
group() {
|
||||
return this.options.inline ? 'inline' : 'block'
|
||||
},
|
||||
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: element => getParsedYoutubeAttributes(element)?.src,
|
||||
},
|
||||
start: {
|
||||
default: 0,
|
||||
parseHTML: element => getParsedYoutubeAttributes(element)?.start,
|
||||
},
|
||||
width: {
|
||||
default: this.options.width,
|
||||
parseHTML: element => getParsedDimension(element.getAttribute('width')),
|
||||
},
|
||||
height: {
|
||||
default: this.options.height,
|
||||
parseHTML: element => getParsedDimension(element.getAttribute('height')),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-youtube-video] iframe',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setYoutubeVideo:
|
||||
(options: SetYoutubeVideoOptions) =>
|
||||
({ commands }) => {
|
||||
if (!isValidYoutubeUrl(options.src)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
if (!this.options.addPasteHandler) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: YOUTUBE_REGEX_GLOBAL,
|
||||
type: this.type,
|
||||
getAttributes: match => {
|
||||
return { src: match.input }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const embedUrl = getEmbedUrlFromYoutubeUrl({
|
||||
url: HTMLAttributes.src,
|
||||
allowFullscreen: this.options.allowFullscreen,
|
||||
autoplay: this.options.autoplay,
|
||||
ccLanguage: this.options.ccLanguage,
|
||||
ccLoadPolicy: this.options.ccLoadPolicy,
|
||||
controls: this.options.controls,
|
||||
disableKBcontrols: this.options.disableKBcontrols,
|
||||
enableIFrameApi: this.options.enableIFrameApi,
|
||||
endTime: this.options.endTime,
|
||||
interfaceLanguage: this.options.interfaceLanguage,
|
||||
ivLoadPolicy: this.options.ivLoadPolicy,
|
||||
loop: this.options.loop,
|
||||
modestBranding: this.options.modestBranding,
|
||||
nocookie: this.options.nocookie,
|
||||
origin: this.options.origin,
|
||||
playlist: this.options.playlist,
|
||||
progressBarColor: this.options.progressBarColor,
|
||||
startAt: HTMLAttributes.start || 0,
|
||||
rel: this.options.rel,
|
||||
})
|
||||
|
||||
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,
|
||||
autoplay: this.options.autoplay,
|
||||
ccLanguage: this.options.ccLanguage,
|
||||
ccLoadPolicy: this.options.ccLoadPolicy,
|
||||
disableKBcontrols: this.options.disableKBcontrols,
|
||||
enableIFrameApi: this.options.enableIFrameApi,
|
||||
endTime: this.options.endTime,
|
||||
interfaceLanguage: this.options.interfaceLanguage,
|
||||
ivLoadPolicy: this.options.ivLoadPolicy,
|
||||
loop: this.options.loop,
|
||||
modestBranding: this.options.modestBranding,
|
||||
origin: this.options.origin,
|
||||
playlist: this.options.playlist,
|
||||
progressBarColor: this.options.progressBarColor,
|
||||
rel: this.options.rel,
|
||||
},
|
||||
HTMLAttributes,
|
||||
),
|
||||
],
|
||||
]
|
||||
},
|
||||
|
||||
...createAtomBlockMarkdownSpec({
|
||||
nodeName: 'youtube',
|
||||
allowedAttributes: ['src', 'width', 'height', 'start'],
|
||||
}),
|
||||
})
|
||||
332
packages/extension-youtube/tests/integration/youtube.test.ts
Normal file
332
packages/extension-youtube/tests/integration/youtube.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import Youtube from '@tiptap/extension-youtube'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('extension-youtube', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
const invalidUrls = [
|
||||
// We have to disable the oxlint rule here because we're trying to purposely test eval urls
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'javascript:alert(window.origin)//embed/',
|
||||
'https://youtube.google.com/embed/fdsafsdf',
|
||||
'https://youtube.com.bad/embed',
|
||||
'https://www.youtu0be/embed',
|
||||
]
|
||||
|
||||
invalidUrls.forEach(url => {
|
||||
it(`does not output html for javascript schema or non-youtube links for url ${url}`, () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).not.toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
it('when nocookie youtube url is passed, still outputs html with iframe with the url', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: 'https://www.youtube-nocookie.com/embed/testvideoid',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain('https://www.youtube-nocookie.com/embed/testvideoid')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
42,
|
||||
],
|
||||
['https://youtu.be/dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 0],
|
||||
[
|
||||
'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
|
||||
'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
|
||||
0,
|
||||
],
|
||||
])(
|
||||
'preserves canonical youtube attrs when content is loaded back from rendered HTML for %s',
|
||||
(originalSrc, expectedSrc, start) => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: originalSrc,
|
||||
start,
|
||||
width: 720,
|
||||
height: 405,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
editor.destroy()
|
||||
getEditorEl()?.remove()
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: html,
|
||||
})
|
||||
|
||||
expect(editor.getJSON()).toMatchObject({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: expectedSrc,
|
||||
start,
|
||||
width: 720,
|
||||
height: 405,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
},
|
||||
)
|
||||
|
||||
describe('iframe dimension handling', () => {
|
||||
it('preserves percentage width and height when parsing HTML', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content:
|
||||
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="100%" height="56.25%"></iframe></div>',
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe('100%')
|
||||
expect(attrs.height).toBe('56.25%')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('keeps percentage dimensions when content is loaded back from rendered HTML', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'youtube',
|
||||
attrs: {
|
||||
src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
width: '100%',
|
||||
height: 480,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toContain('width="100%"')
|
||||
|
||||
editor.destroy()
|
||||
getEditorEl()?.remove()
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: html,
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe('100%')
|
||||
expect(attrs.height).toBe(480)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('still parses plain numeric dimensions as numbers', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content:
|
||||
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="720" height="405"></iframe></div>',
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe(720)
|
||||
expect(attrs.height).toBe(405)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['100px', '200px'],
|
||||
['20rem', '10em'],
|
||||
['50vw', '30vh'],
|
||||
])('preserves unit-based dimensions like %s when parsing HTML', (width, height) => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: `<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="${width}" height="${height}"></iframe></div>`,
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe(width)
|
||||
expect(attrs.height).toBe(height)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('preserves non-numeric strings instead of falling back to the default dimensions', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content:
|
||||
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="not-a-size" height="auto"></iframe></div>',
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe('not-a-size')
|
||||
expect(attrs.height).toBe('auto')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('accepts a percentage width in the setYoutubeVideo command', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
})
|
||||
|
||||
editor.commands.setYoutubeVideo({
|
||||
src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
width: '100%',
|
||||
height: 405,
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
|
||||
expect(attrs.width).toBe('100%')
|
||||
expect(attrs.height).toBe(405)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not persist NaN width or height when parsing iframe dimensions from HTML', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content:
|
||||
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="100%" height="auto"></iframe></div>',
|
||||
})
|
||||
|
||||
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(Number.isNaN(attrs.width)).toBe(false)
|
||||
expect(Number.isNaN(attrs.height)).toBe(false)
|
||||
expect(html).not.toContain('width="NaN"')
|
||||
expect(html).not.toContain('height="NaN"')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
describe('missing src attribute', () => {
|
||||
// The `src` attribute is declared with `default: null` and its `parseHTML` returns
|
||||
// `undefined` when the iframe carries no `src`, so a node with a null `src` is a
|
||||
// legitimate parse result rather than a malformed document.
|
||||
const iframeWithoutSrc =
|
||||
'<div data-youtube-video=""><iframe class="w-full aspect-video" width="640" height="480" allowfullscreen="true" start="0"></iframe></div>'
|
||||
|
||||
it('refuses setYoutubeVideo with a missing src instead of throwing', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
})
|
||||
|
||||
expect(editor.commands.setYoutubeVideo({ src: null as unknown as string })).toBe(false)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('parses an iframe without a src attribute without throwing', () => {
|
||||
expect(() => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Youtube],
|
||||
content: iframeWithoutSrc,
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(editor?.getHTML()).not.toContain('src=')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
9
packages/extension-youtube/vite.config.ts
Normal file
9
packages/extension-youtube/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user