tiptapify 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +34 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
- package/src/extensions/components/media/image/imageDialog.ts +114 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +8 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- package/src/utils/validateHref.ts +47 -7
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { Document } from '@tiptap/extension-document'
|
|
4
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
5
|
+
import { Text } from '@tiptap/extension-text'
|
|
6
|
+
import { Youtube } from '@tiptap/extension-youtube'
|
|
7
|
+
import Iframe from '@tiptapify/extensions/components/media/iframe'
|
|
8
|
+
import { applyVideoNode, selectedVideoNode, TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
|
|
9
|
+
import { videoProviders } from '@tiptapify/extensions/components/media/video/providers'
|
|
10
|
+
import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
|
|
11
|
+
|
|
12
|
+
let editor: Editor | null = null
|
|
13
|
+
|
|
14
|
+
function createEditor(content: string) {
|
|
15
|
+
editor = new Editor({
|
|
16
|
+
element: document.createElement('div'),
|
|
17
|
+
content,
|
|
18
|
+
extensions: [
|
|
19
|
+
Document,
|
|
20
|
+
Paragraph,
|
|
21
|
+
Text,
|
|
22
|
+
Youtube.configure({ nocookie: true, controls: true }),
|
|
23
|
+
Iframe,
|
|
24
|
+
VideoFile,
|
|
25
|
+
TiptapifyVideo,
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return editor
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function selectFirstNode() {
|
|
33
|
+
editor!.commands.setNodeSelection(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [youtube, vimeo] = videoProviders
|
|
37
|
+
|
|
38
|
+
describe('video dialog apply', () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
editor?.destroy()
|
|
41
|
+
editor = null
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('finds no video to edit when none is selected', () => {
|
|
45
|
+
createEditor('<p>Text</p>')
|
|
46
|
+
|
|
47
|
+
expect(selectedVideoNode(editor!)).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not edit an iframe of another site', () => {
|
|
51
|
+
createEditor('<iframe src="https://maps.example.com/embed"></iframe>')
|
|
52
|
+
selectFirstNode()
|
|
53
|
+
|
|
54
|
+
expect(selectedVideoNode(editor!)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('finds the selected Vimeo iframe', () => {
|
|
58
|
+
createEditor('<iframe src="https://player.vimeo.com/video/123#t=5s" width="640" height="360"></iframe>')
|
|
59
|
+
selectFirstNode()
|
|
60
|
+
|
|
61
|
+
expect(selectedVideoNode(editor!)).toMatchObject({
|
|
62
|
+
type: 'iframe',
|
|
63
|
+
attrs: { src: 'https://player.vimeo.com/video/123#t=5s', width: 640, height: 360 },
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('inserts a responsive YouTube video without a size', () => {
|
|
68
|
+
createEditor('<p></p>')
|
|
69
|
+
|
|
70
|
+
applyVideoNode(editor!, youtube.toNode('dQw4w9WgXcQ', 42, { width: null, height: null }), null)
|
|
71
|
+
|
|
72
|
+
const html = editor!.getHTML()
|
|
73
|
+
expect(html).toContain('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=42&rel=1"')
|
|
74
|
+
expect(html).not.toContain('width=')
|
|
75
|
+
expect(html).not.toContain('height=')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('updates the selected video and keeps the attributes the dialog does not know', () => {
|
|
79
|
+
createEditor('<video src="/a.mp4" controls loop></video>')
|
|
80
|
+
selectFirstNode()
|
|
81
|
+
const edited = selectedVideoNode(editor!)
|
|
82
|
+
|
|
83
|
+
applyVideoNode(editor!, { type: 'video', attrs: { poster: '/a.jpg', loop: false } }, edited)
|
|
84
|
+
|
|
85
|
+
expect(editor!.state.doc.childCount).toBe(1)
|
|
86
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: '/a.mp4', poster: '/a.jpg', controls: true, loop: false })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('replaces the selected video when the provider changes', () => {
|
|
90
|
+
createEditor('<div data-youtube-video><iframe src="https://www.youtube-nocookie.com/embed/abc"></iframe></div>')
|
|
91
|
+
selectFirstNode()
|
|
92
|
+
const edited = selectedVideoNode(editor!)
|
|
93
|
+
|
|
94
|
+
expect(edited?.type).toBe('youtube')
|
|
95
|
+
|
|
96
|
+
applyVideoNode(editor!, vimeo.toNode('123', 0, { width: null, height: null }), edited)
|
|
97
|
+
|
|
98
|
+
expect(editor!.state.doc.childCount).toBe(1)
|
|
99
|
+
expect(editor!.state.doc.firstChild!.type.name).toBe('iframe')
|
|
100
|
+
expect(editor!.state.doc.firstChild!.attrs.src).toBe('https://player.vimeo.com/video/123')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('a video block in the editor', () => {
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
editor?.destroy()
|
|
107
|
+
editor = null
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// A double click on the block (its player does not take clicks while editing) opens the dialog for it
|
|
111
|
+
function doubleClickNode(pos: number) {
|
|
112
|
+
const { view } = editor!
|
|
113
|
+
const node = view.state.doc.nodeAt(pos)!
|
|
114
|
+
|
|
115
|
+
return view.someProp('handleDoubleClickOn', handle => handle(view, pos, node, pos, new MouseEvent('dblclick'), true))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
['an uploaded video', '<video src="https://cdn.example.com/a.mp4" controls></video>', 'video'],
|
|
120
|
+
['a YouTube video', '<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe></div>', 'youtube'],
|
|
121
|
+
['a Vimeo video', '<iframe src="https://player.vimeo.com/video/76979871"></iframe>', 'iframe'],
|
|
122
|
+
])('%s is selected and opens the video dialog on double click', (_, content, type) => {
|
|
123
|
+
createEditor(content)
|
|
124
|
+
const listener = vi.fn()
|
|
125
|
+
window.addEventListener('tiptapify-show-tiptapifyVideo', listener)
|
|
126
|
+
|
|
127
|
+
const handled = doubleClickNode(0)
|
|
128
|
+
|
|
129
|
+
window.removeEventListener('tiptapify-show-tiptapifyVideo', listener)
|
|
130
|
+
expect(handled).toBe(true)
|
|
131
|
+
expect(editor!.state.selection.from).toBe(0)
|
|
132
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
133
|
+
expect(listener.mock.calls[0][0].detail.node.type).toBe(type)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('leaves other iframes to the embed dialog', () => {
|
|
137
|
+
createEditor('<iframe src="https://maps.example.com/embed"></iframe>')
|
|
138
|
+
const listener = vi.fn()
|
|
139
|
+
window.addEventListener('tiptapify-show-tiptapifyVideo', listener)
|
|
140
|
+
|
|
141
|
+
doubleClickNode(0)
|
|
142
|
+
|
|
143
|
+
window.removeEventListener('tiptapify-show-tiptapifyVideo', listener)
|
|
144
|
+
expect(listener).not.toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('wraps the player of an uploaded video in a block that takes the clicks', () => {
|
|
148
|
+
createEditor('<video src="https://cdn.example.com/a.mp4" controls></video>')
|
|
149
|
+
|
|
150
|
+
const block = editor!.view.nodeDOM(0) as HTMLElement
|
|
151
|
+
expect(block.classList.contains('tiptapify-video')).toBe(true)
|
|
152
|
+
expect(block.querySelector('video')!.getAttribute('src')).toBe('https://cdn.example.com/a.mp4')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { Extension } from '@tiptap/core'
|
|
1
|
+
import { Editor, Extension } from '@tiptap/core'
|
|
2
|
+
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
3
|
+
import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
import { parseVideoUrl, VideoNodeContent } from '@tiptapify/extensions/components/media/video/providers'
|
|
2
5
|
|
|
3
6
|
const name: string = 'tiptapifyVideo'
|
|
4
7
|
|
|
@@ -10,6 +13,44 @@ declare module '@tiptap/core' {
|
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The selected node the video dialog edits: a youtube or `video` node, or an iframe with
|
|
18
|
+
* a provider's player (Vimeo); null when none is selected.
|
|
19
|
+
*/
|
|
20
|
+
export function selectedVideoNode(editor: Editor): VideoNodeContent | null {
|
|
21
|
+
const { selection } = editor.state
|
|
22
|
+
|
|
23
|
+
if (!(selection instanceof NodeSelection)) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { type, attrs } = selection.node
|
|
28
|
+
|
|
29
|
+
return isVideoNode(selection.node) ? { type: type.name, attrs: { ...attrs } } : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isVideoNode({ type, attrs }: ProseMirrorNode): boolean {
|
|
33
|
+
return type.name === 'youtube' || type.name === 'video' || parseVideoUrl(attrs.src ?? '')?.provider.nodeType === type.name
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Inserts the video node, or updates the selected one being edited. The edited node keeps the
|
|
38
|
+
* attributes the dialog does not know about; a node of another type (another provider) replaces it.
|
|
39
|
+
*/
|
|
40
|
+
export function applyVideoNode(editor: Editor, content: VideoNodeContent, edited: VideoNodeContent | null): boolean {
|
|
41
|
+
if (edited?.type === content.type) {
|
|
42
|
+
return editor.chain().focus().updateAttributes(content.type, content.attrs).run()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (edited) {
|
|
46
|
+
const { from, to } = editor.state.selection
|
|
47
|
+
|
|
48
|
+
return editor.chain().focus().insertContentAt({ from, to }, content).run()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return editor.chain().focus().insertContent(content).run()
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
export const TiptapifyVideo = Extension.create({
|
|
14
55
|
name,
|
|
15
56
|
|
|
@@ -18,9 +59,9 @@ export const TiptapifyVideo = Extension.create({
|
|
|
18
59
|
showTiptapifyVideo: () => ({ editor }) => {
|
|
19
60
|
const event = new CustomEvent(`tiptapify-show-${name}`, {
|
|
20
61
|
detail: {
|
|
21
|
-
|
|
22
|
-
editorId: editor.instanceId
|
|
23
|
-
}
|
|
62
|
+
node: selectedVideoNode(editor),
|
|
63
|
+
editorId: editor.instanceId,
|
|
64
|
+
},
|
|
24
65
|
})
|
|
25
66
|
|
|
26
67
|
window.dispatchEvent(event)
|
|
@@ -29,4 +70,28 @@ export const TiptapifyVideo = Extension.create({
|
|
|
29
70
|
},
|
|
30
71
|
}
|
|
31
72
|
},
|
|
32
|
-
|
|
73
|
+
|
|
74
|
+
// While editing, a player takes no clicks (see the editor styles): a click selects the video block, and
|
|
75
|
+
// a double click opens the dialog for it
|
|
76
|
+
addProseMirrorPlugins() {
|
|
77
|
+
const { editor } = this
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
new Plugin({
|
|
81
|
+
key: new PluginKey(name),
|
|
82
|
+
props: {
|
|
83
|
+
handleDoubleClickOn: (_view, _pos, node, nodePos) => {
|
|
84
|
+
if (!isVideoNode(node)) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
editor.commands.setNodeSelection(nodePos)
|
|
89
|
+
editor.commands.showTiptapifyVideo()
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
formatTimecode,
|
|
4
|
+
isVideoFileWithoutProtocol,
|
|
5
|
+
parseTimecode,
|
|
6
|
+
parseVideoUrl,
|
|
7
|
+
videoProviders,
|
|
8
|
+
} from '@tiptapify/extensions/components/media/video/providers'
|
|
9
|
+
|
|
10
|
+
function link(input: string) {
|
|
11
|
+
const parsed = parseVideoUrl(input)
|
|
12
|
+
|
|
13
|
+
return parsed && { provider: parsed.provider.name, id: parsed.id, start: parsed.start }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('video providers', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
|
19
|
+
['https://m.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1', 'dQw4w9WgXcQ'],
|
|
20
|
+
['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
|
21
|
+
['youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
|
22
|
+
['https://www.youtube.com/shorts/abc_-123', 'abc_-123'],
|
|
23
|
+
['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
|
24
|
+
['https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
|
25
|
+
])('recognizes the YouTube link %s', (input, id) => {
|
|
26
|
+
expect(link(input)).toEqual({ provider: 'youtube', id, start: 0 })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
['https://vimeo.com/76979871', '76979871'],
|
|
31
|
+
['https://player.vimeo.com/video/76979871', '76979871'],
|
|
32
|
+
])('recognizes the Vimeo link %s', (input, id) => {
|
|
33
|
+
expect(link(input)).toEqual({ provider: 'vimeo', id, start: 0 })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
'https://example.com/video.html',
|
|
38
|
+
'https://www.youtube.com/',
|
|
39
|
+
'https://www.youtube.com/watch',
|
|
40
|
+
'https://vimeo.com/channels',
|
|
41
|
+
'https://notyoutube.com/watch?v=dQw4w9WgXcQ',
|
|
42
|
+
'not a link',
|
|
43
|
+
'',
|
|
44
|
+
])('does not recognize %s', (input) => {
|
|
45
|
+
expect(parseVideoUrl(input)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
['https://youtu.be/dQw4w9WgXcQ?t=42', 42],
|
|
50
|
+
['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m2s', 62],
|
|
51
|
+
['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h0m5s', 3605],
|
|
52
|
+
['https://www.youtube.com/embed/dQw4w9WgXcQ?start=90', 90],
|
|
53
|
+
['https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=42', 42],
|
|
54
|
+
['https://vimeo.com/76979871#t=42s', 42],
|
|
55
|
+
['https://player.vimeo.com/video/76979871#t=1m30s', 90],
|
|
56
|
+
])('reads the start time of %s', (input, start) => {
|
|
57
|
+
expect(parseVideoUrl(input)?.start).toBe(start)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('builds the preview player URLs', () => {
|
|
61
|
+
const [youtube, vimeo] = videoProviders
|
|
62
|
+
|
|
63
|
+
expect(youtube.embedUrl('abc', 0)).toBe('https://www.youtube-nocookie.com/embed/abc')
|
|
64
|
+
expect(youtube.embedUrl('abc', 42)).toBe('https://www.youtube-nocookie.com/embed/abc?start=42')
|
|
65
|
+
expect(vimeo.embedUrl('123', 0)).toBe('https://player.vimeo.com/video/123')
|
|
66
|
+
expect(vimeo.embedUrl('123', 42)).toBe('https://player.vimeo.com/video/123#t=42s')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('stores YouTube as a youtube node and Vimeo as an iframe', () => {
|
|
70
|
+
const [youtube, vimeo] = videoProviders
|
|
71
|
+
|
|
72
|
+
expect(youtube.toNode('abc', 42, { width: null, height: null })).toEqual({
|
|
73
|
+
type: 'youtube',
|
|
74
|
+
attrs: { src: 'https://www.youtube.com/watch?v=abc', start: 42, width: null, height: null },
|
|
75
|
+
})
|
|
76
|
+
expect(vimeo.toNode('123', 5, { width: 640, height: 360 })).toEqual({
|
|
77
|
+
type: 'iframe',
|
|
78
|
+
attrs: { src: 'https://player.vimeo.com/video/123#t=5s', width: 640, height: 360 },
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('timecodes', () => {
|
|
84
|
+
it.each([
|
|
85
|
+
['', 0],
|
|
86
|
+
[' ', 0],
|
|
87
|
+
['42', 42],
|
|
88
|
+
['1:30', 90],
|
|
89
|
+
['0:05', 5],
|
|
90
|
+
['1:02:03', 3723],
|
|
91
|
+
])('parses "%s" as %i seconds', (input, seconds) => {
|
|
92
|
+
expect(parseTimecode(input)).toBe(seconds)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it.each(['1:60', '1:61:00', 'abc', '1:2:3:4', '-5', '1.5', '1:'])('rejects "%s"', (input) => {
|
|
96
|
+
expect(parseTimecode(input)).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
[0, ''],
|
|
101
|
+
[5, '0:05'],
|
|
102
|
+
[90, '1:30'],
|
|
103
|
+
[3723, '1:02:03'],
|
|
104
|
+
])('formats %i seconds as "%s"', (seconds, text) => {
|
|
105
|
+
expect(formatTimecode(seconds)).toBe(text)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('video file links', () => {
|
|
110
|
+
it.each([
|
|
111
|
+
['https://www.w3schools.com/tags/mov_bbb.mp4', 'https://www.w3schools.com/tags/mov_bbb.mp4', 'MP4'],
|
|
112
|
+
['http://intranet/media/clip.webm?v=2', 'http://intranet/media/clip.webm?v=2', 'WEBM'],
|
|
113
|
+
['/media/clip.ogv', '/media/clip.ogv', 'OGV'],
|
|
114
|
+
['//cdn.example.com/a.MOV', '//cdn.example.com/a.MOV', 'MOV'],
|
|
115
|
+
])('%s is a video file played by the <video> node', (input, src, label) => {
|
|
116
|
+
const link = parseVideoUrl(input)!
|
|
117
|
+
|
|
118
|
+
expect(link.provider.nodeType).toBe('video')
|
|
119
|
+
expect(link.label).toBe(label)
|
|
120
|
+
expect(link.provider.toNode(link.id, 0, { width: null, height: null })).toEqual({
|
|
121
|
+
type: 'video',
|
|
122
|
+
attrs: { src, start: 0, width: null, height: null },
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('takes the start from the #t= fragment and keeps it out of the src', () => {
|
|
127
|
+
const link = parseVideoUrl('https://cdn.example.com/a.mp4#t=42')!
|
|
128
|
+
|
|
129
|
+
expect([link.id, link.start]).toEqual(['https://cdn.example.com/a.mp4', 42])
|
|
130
|
+
expect(link.provider.embedUrl(link.id, 42)).toBe('https://cdn.example.com/a.mp4#t=42')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('never guesses a protocol for a file link', () => {
|
|
134
|
+
expect(parseVideoUrl('cdn.example.com/a.mp4')).toBeNull()
|
|
135
|
+
expect(isVideoFileWithoutProtocol('cdn.example.com/a.mp4')).toBe(true)
|
|
136
|
+
expect(isVideoFileWithoutProtocol('https://cdn.example.com/a.mp4')).toBe(false)
|
|
137
|
+
expect(isVideoFileWithoutProtocol('youtu.be/dQw4w9WgXcQ')).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is not a video file without a video extension', () => {
|
|
141
|
+
expect(parseVideoUrl('https://example.com/page.html')).toBeNull()
|
|
142
|
+
expect(parseVideoUrl('https://example.com/mp4')).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('labels the embedded players with the provider name', () => {
|
|
146
|
+
expect(parseVideoUrl('https://youtu.be/dQw4w9WgXcQ')!.label).toBe('YouTube')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
export type VideoProviderName = 'youtube' | 'vimeo' | 'file'
|
|
2
|
+
|
|
3
|
+
export type VideoSize = { width: number | null, height: number | null }
|
|
4
|
+
|
|
5
|
+
export type VideoNodeContent = { type: string, attrs: Record<string, unknown> }
|
|
6
|
+
|
|
7
|
+
export interface VideoProvider {
|
|
8
|
+
name: VideoProviderName
|
|
9
|
+
label: string
|
|
10
|
+
// Whether the player can start at a given time (shows the "Start at" field)
|
|
11
|
+
supportsStart: boolean
|
|
12
|
+
// The editor node the provider's videos are stored in
|
|
13
|
+
nodeType: string
|
|
14
|
+
// The video id from a parsed link, or null when the link is not this provider's
|
|
15
|
+
parseId: (url: URL) => string | null
|
|
16
|
+
// The player URL for the preview
|
|
17
|
+
embedUrl: (id: string, start: number) => string
|
|
18
|
+
toNode: (id: string, start: number, size: VideoSize) => VideoNodeContent
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `label` is the badge of the dialog: the provider name, or the format of a video file (MP4)
|
|
22
|
+
export type VideoLink = { provider: VideoProvider, id: string, start: number, label: string }
|
|
23
|
+
|
|
24
|
+
const youtubeHosts = ['youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com']
|
|
25
|
+
|
|
26
|
+
const youtube: VideoProvider = {
|
|
27
|
+
name: 'youtube',
|
|
28
|
+
label: 'YouTube',
|
|
29
|
+
supportsStart: true,
|
|
30
|
+
nodeType: 'youtube',
|
|
31
|
+
parseId(url) {
|
|
32
|
+
const host = url.hostname.replace(/^www\./, '')
|
|
33
|
+
|
|
34
|
+
if (host === 'youtu.be') {
|
|
35
|
+
return url.pathname.match(/^\/([\w-]+)/)?.[1] ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!youtubeHosts.includes(host)) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (url.pathname === '/watch') {
|
|
43
|
+
return url.searchParams.get('v')?.match(/^[\w-]+$/)?.[0] ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return url.pathname.match(/^\/(?:shorts|embed|live|v)\/([\w-]+)/)?.[1] ?? null
|
|
47
|
+
},
|
|
48
|
+
embedUrl: (id, start) => `https://www.youtube-nocookie.com/embed/${id}${start ? `?start=${start}` : ''}`,
|
|
49
|
+
// The youtube node builds the (nocookie) player URL itself from a watch link and its start
|
|
50
|
+
toNode: (id, start, size) => ({
|
|
51
|
+
type: 'youtube',
|
|
52
|
+
attrs: { src: `https://www.youtube.com/watch?v=${id}`, start, ...size },
|
|
53
|
+
}),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const vimeo: VideoProvider = {
|
|
57
|
+
name: 'vimeo',
|
|
58
|
+
label: 'Vimeo',
|
|
59
|
+
supportsStart: true,
|
|
60
|
+
nodeType: 'iframe',
|
|
61
|
+
parseId(url) {
|
|
62
|
+
const host = url.hostname.replace(/^www\./, '')
|
|
63
|
+
|
|
64
|
+
if (host === 'vimeo.com') {
|
|
65
|
+
return url.pathname.match(/^\/(\d+)(?:\/|$)/)?.[1] ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (host === 'player.vimeo.com') {
|
|
69
|
+
return url.pathname.match(/^\/video\/(\d+)/)?.[1] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null
|
|
73
|
+
},
|
|
74
|
+
embedUrl: (id, start) => `https://player.vimeo.com/video/${id}${start ? `#t=${start}s` : ''}`,
|
|
75
|
+
// Vimeo has no node of its own: it is stored as a plain iframe with the player URL
|
|
76
|
+
toNode: (id, start, size) => ({
|
|
77
|
+
type: 'iframe',
|
|
78
|
+
attrs: { src: vimeo.embedUrl(id, start), ...size },
|
|
79
|
+
}),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A new provider is added here; the dialog's badge shows its label
|
|
83
|
+
export const videoProviders: VideoProvider[] = [youtube, vimeo]
|
|
84
|
+
|
|
85
|
+
const videoFileRegex = /\.(mp4|m4v|webm|ogv|ogg|mov)$/i
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A link straight to a video file, played by the browser's own player (the `video` node). Its `id` is the
|
|
89
|
+
* file address as typed, without the `#t=` start: an absolute http(s) link, a protocol-relative `//host`
|
|
90
|
+
* or a path on the site. A protocol is never guessed: `cdn.example.com/a.mp4` could be http or https.
|
|
91
|
+
*/
|
|
92
|
+
export const videoFileProvider: VideoProvider = {
|
|
93
|
+
name: 'file',
|
|
94
|
+
label: '',
|
|
95
|
+
supportsStart: true,
|
|
96
|
+
nodeType: 'video',
|
|
97
|
+
parseId(url) {
|
|
98
|
+
return videoFileRegex.test(url.pathname) ? url.href.replace(/#.*$/, '') : null
|
|
99
|
+
},
|
|
100
|
+
embedUrl: (id, start) => `${id}${start ? `#t=${start}` : ''}`,
|
|
101
|
+
toNode: (id, start, size) => ({ type: 'video', attrs: { src: id, start, ...size } }),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A video file address typed without a protocol (`cdn.example.com/a.mp4`): the dialog asks for one
|
|
105
|
+
export function isVideoFileWithoutProtocol(input: string): boolean {
|
|
106
|
+
const value = input.trim()
|
|
107
|
+
|
|
108
|
+
return !/^[a-z][a-z\d+.-]*:|^\//i.test(value) && videoFileRegex.test(value.replace(/[?#].*$/, ''))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseVideoFile(input: string): VideoLink | null {
|
|
112
|
+
const value = input.trim()
|
|
113
|
+
|
|
114
|
+
if (!/^(https?:)?\/\/\S+$|^\/\S+$/i.test(value)) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A base only to read the path and the fragment of a relative address; the address itself is kept as typed
|
|
119
|
+
const url = new URL(value, 'https://relative.invalid')
|
|
120
|
+
const extension = url.pathname.match(videoFileRegex)?.[1]
|
|
121
|
+
|
|
122
|
+
if (!extension) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
provider: videoFileProvider,
|
|
128
|
+
id: value.replace(/#.*$/, ''),
|
|
129
|
+
start: parseUrlStart(url),
|
|
130
|
+
label: extension.toUpperCase(),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toUrl(input: string): URL | null {
|
|
135
|
+
const value = input.trim()
|
|
136
|
+
|
|
137
|
+
if (!value) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Links pasted without a scheme ("youtu.be/…") are common
|
|
143
|
+
return new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`)
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Seconds from a link time value: "42", "42s", "1m2s", "1h2m3s"; 0 when it is not one.
|
|
151
|
+
*/
|
|
152
|
+
function parseDuration(value: string | null): number {
|
|
153
|
+
const match = value?.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
|
|
154
|
+
|
|
155
|
+
if (!match) {
|
|
156
|
+
return 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const [, hours, minutes, seconds] = match
|
|
160
|
+
|
|
161
|
+
return Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The start time a link carries: `?t=42`, `?t=1m2s`, `?start=42` or `#t=42`; 0 when there is none.
|
|
166
|
+
*/
|
|
167
|
+
export function parseUrlStart(url: URL): number {
|
|
168
|
+
const hashTime = new URLSearchParams(url.hash.replace(/^#/, '')).get('t')
|
|
169
|
+
|
|
170
|
+
return parseDuration(url.searchParams.get('t'))
|
|
171
|
+
|| parseDuration(url.searchParams.get('start'))
|
|
172
|
+
|| parseDuration(hashTime)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The provider, video id and start time of a link; null when no provider recognizes it.
|
|
177
|
+
*/
|
|
178
|
+
export function parseVideoUrl(input: string): VideoLink | null {
|
|
179
|
+
const file = parseVideoFile(input)
|
|
180
|
+
|
|
181
|
+
if (file) {
|
|
182
|
+
return file
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const url = toUrl(input)
|
|
186
|
+
|
|
187
|
+
if (!url) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const provider of videoProviders) {
|
|
192
|
+
const id = provider.parseId(url)
|
|
193
|
+
|
|
194
|
+
if (id) {
|
|
195
|
+
return { provider, id, start: provider.supportsStart ? parseUrlStart(url) : 0, label: provider.label }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Seconds from a time typed in the dialog ("1:30", "1:02:03" or plain seconds); 0 for an empty
|
|
204
|
+
* value and null for an invalid one.
|
|
205
|
+
*/
|
|
206
|
+
export function parseTimecode(value: string): number | null {
|
|
207
|
+
const trimmed = value.trim()
|
|
208
|
+
|
|
209
|
+
if (!trimmed) {
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!/^\d+(?::\d{1,2}){0,2}$/.test(trimmed)) {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const parts = trimmed.split(':').map(Number)
|
|
218
|
+
|
|
219
|
+
// Every part after the first is a minute or a second part
|
|
220
|
+
if (parts.slice(1).some(part => part > 59)) {
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return parts.reduce((total, part) => total * 60 + part, 0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* "m:ss" or "h:mm:ss" for seconds; an empty string for 0.
|
|
229
|
+
*/
|
|
230
|
+
export function formatTimecode(seconds: number): string {
|
|
231
|
+
if (!seconds || seconds < 0) {
|
|
232
|
+
return ''
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const whole = Math.floor(seconds)
|
|
236
|
+
const hours = Math.floor(whole / 3600)
|
|
237
|
+
const minutes = Math.floor((whole % 3600) / 60)
|
|
238
|
+
const secs = String(whole % 60).padStart(2, '0')
|
|
239
|
+
|
|
240
|
+
return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${secs}` : `${minutes}:${secs}`
|
|
241
|
+
}
|