tiptapify 0.3.4 → 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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110820 -65669
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -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&amp;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
- video: editor.getAttributes('video'),
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
+ }