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,153 @@
1
+ import { Editor, Extension } from '@tiptap/core'
2
+ import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
3
+ import { LinkCardAttrs } from '@tiptapify/extensions/components/media/linkCard'
4
+
5
+ // A link pasted alone into an empty paragraph: its paragraph (mapped through later changes) and its address
6
+ export type PastedLinkState = { pos: number, url: string }
7
+
8
+ // The pasted link the menu offers to show as a card: the paragraph range and the link text range in it
9
+ export type PastedLink = { url: string, from: number, to: number, paragraphFrom: number, paragraphTo: number }
10
+
11
+ // The class of the paste menu: the editor losing focus to it keeps the pasted link
12
+ export const PASTE_CARD_MENU_CLASS = 'tiptapify-link-card-paste'
13
+
14
+ export const linkCardPasteKey = new PluginKey<PastedLinkState | null>('linkCardPaste')
15
+
16
+ /**
17
+ * The text as one http(s) address, as typed (no protocol of our own), or null: anything else, spaces
18
+ * inside included, is ordinary text.
19
+ */
20
+ export function pastedLinkUrl(text: string | null | undefined): string | null {
21
+ const url = (text ?? '').trim()
22
+
23
+ if (!url || /\s/.test(url)) {
24
+ return null
25
+ }
26
+
27
+ try {
28
+ const { protocol } = new URL(url)
29
+
30
+ return protocol === 'http:' || protocol === 'https:' ? url : null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function isEmptyParagraph(state: EditorState): boolean {
37
+ const { selection } = state
38
+ const parent = selection.$from.parent
39
+
40
+ return selection.empty && parent.type.name === 'paragraph' && parent.content.size === 0
41
+ }
42
+
43
+ // The paragraph a paste into an empty paragraph produced, when it holds just one address
44
+ function trackPaste(tr: Transaction, oldState: EditorState): PastedLinkState | null {
45
+ if (!isEmptyParagraph(oldState)) {
46
+ return null
47
+ }
48
+
49
+ const { $from } = tr.selection
50
+ const url = $from.parent.type.name === 'paragraph' ? pastedLinkUrl($from.parent.textContent) : null
51
+
52
+ return url && $from.depth > 0 ? { pos: $from.before(), url } : null
53
+ }
54
+
55
+ function blurredToMenu(meta: unknown): boolean {
56
+ const target = (meta as { event?: FocusEvent } | undefined)?.event?.relatedTarget
57
+
58
+ return target instanceof Element && !!target.closest(`.${PASTE_CARD_MENU_CLASS}`)
59
+ }
60
+
61
+ function applyTransaction(tr: Transaction, value: PastedLinkState | null, oldState: EditorState): PastedLinkState | null {
62
+ if (tr.getMeta('paste') || tr.getMeta('uiEvent') === 'paste') {
63
+ return trackPaste(tr, oldState)
64
+ }
65
+
66
+ if (!value || tr.getMeta(linkCardPasteKey)?.dismiss) {
67
+ return null
68
+ }
69
+
70
+ // What the paste brought along: the link mark of the paste rules
71
+ if (tr.getMeta('appendedTransaction')) {
72
+ return { ...value, pos: tr.mapping.map(value.pos) }
73
+ }
74
+
75
+ // Typing, a moved selection or the editor left for anything but the menu
76
+ if (tr.docChanged || !tr.selection.eq(oldState.selection) || (tr.getMeta('blur') && !blurredToMenu(tr.getMeta('blur')))) {
77
+ return null
78
+ }
79
+
80
+ return value
81
+ }
82
+
83
+ /**
84
+ * The pasted link with its ranges, while it is still the whole text of its paragraph.
85
+ */
86
+ export function pastedLink(state: EditorState): PastedLink | null {
87
+ const tracked = linkCardPasteKey.getState(state)
88
+ const node = tracked ? state.doc.nodeAt(tracked.pos) : null
89
+
90
+ if (!tracked || !node || node.type.name !== 'paragraph' || pastedLinkUrl(node.textContent) !== tracked.url) {
91
+ return null
92
+ }
93
+
94
+ const paragraphTo = tracked.pos + node.nodeSize
95
+
96
+ return { url: tracked.url, from: tracked.pos + 1, to: paragraphTo - 1, paragraphFrom: tracked.pos, paragraphTo }
97
+ }
98
+
99
+ export function dismissPastedLink(editor: Editor): void {
100
+ if (linkCardPasteKey.getState(editor.state)) {
101
+ editor.view.dispatch(editor.state.tr.setMeta(linkCardPasteKey, { dismiss: true }).setMeta('addToHistory', false))
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The paragraph of the pasted link replaced with the card of that link; false when the link is gone
107
+ * (the author typed or moved on meanwhile).
108
+ */
109
+ export function replacePastedLink(editor: Editor, attrs: LinkCardAttrs): boolean {
110
+ const link = pastedLink(editor.state)
111
+
112
+ if (!link || link.url !== attrs.href) {
113
+ return false
114
+ }
115
+
116
+ return editor.chain()
117
+ .focus()
118
+ .insertContentAt({ from: link.paragraphFrom, to: link.paragraphTo }, { type: 'linkCard', attrs })
119
+ .setMeta(linkCardPasteKey, { dismiss: true })
120
+ .run()
121
+ }
122
+
123
+ /**
124
+ * Remembers a link pasted alone into an empty paragraph, so the paste menu can offer it as a card. The
125
+ * paste itself is left as it is (a link); nothing is replaced unless the author picks the card. Any
126
+ * typing, a moved selection, leaving the editor or Esc forgets it.
127
+ */
128
+ export const LinkCardPaste = Extension.create({
129
+ name: 'linkCardPaste',
130
+
131
+ addProseMirrorPlugins() {
132
+ return [
133
+ new Plugin<PastedLinkState | null>({
134
+ key: linkCardPasteKey,
135
+ state: {
136
+ init: () => null,
137
+ apply: (tr, value, oldState) => applyTransaction(tr, value, oldState),
138
+ },
139
+ props: {
140
+ handleKeyDown: (view, event) => {
141
+ if (event.key !== 'Escape' || !linkCardPasteKey.getState(view.state)) {
142
+ return false
143
+ }
144
+
145
+ view.dispatch(view.state.tr.setMeta(linkCardPasteKey, { dismiss: true }).setMeta('addToHistory', false))
146
+
147
+ return true
148
+ },
149
+ },
150
+ }),
151
+ ]
152
+ },
153
+ })
@@ -4,6 +4,7 @@ import { Editor } from '@tiptap/vue-3'
4
4
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
5
5
  import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
6
  import VideoDialog from '@tiptapify/extensions/components/media/video/VideoDialog.vue'
7
+ import { selectedVideoNode } from '@tiptapify/extensions/components/media/video'
7
8
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
9
  import { inject, Ref } from 'vue'
9
10
 
@@ -20,7 +21,7 @@ const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" vie
20
21
 
21
22
  <template>
22
23
  <VBtn
23
- :color="editor.isActive('video') ? 'primary' : ''"
24
+ :color="selectedVideoNode(editor) ? 'primary' : ''"
24
25
  :variant="variantBtn"
25
26
  size="32"
26
27
  @click="editor.commands.showTiptapifyVideo()"
@@ -0,0 +1,233 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { App, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { Document } from '@tiptap/extension-document'
6
+ import { Paragraph } from '@tiptap/extension-paragraph'
7
+ import { Text } from '@tiptap/extension-text'
8
+ import { Youtube } from '@tiptap/extension-youtube'
9
+ import Iframe from '@tiptapify/extensions/components/media/iframe'
10
+ import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
11
+ import VideoDialog from '@tiptapify/extensions/components/media/video/VideoDialog.vue'
12
+ import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
13
+ import { fakeUploadEndpoint } from '@tiptapify/composables/fakeUploadEndpoint'
14
+ import { TIPTAPIFY_UPLOAD_KEY } from '@tiptapify/composables/useTiptapifyUpload'
15
+ import { TiptapifyUploadOptions } from '@tiptapify/types/editor'
16
+
17
+ // jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
18
+ if (!('visualViewport' in globalThis)) {
19
+ ;(globalThis as Record<string, unknown>).visualViewport = {
20
+ scale: 1,
21
+ offsetLeft: 0,
22
+ offsetTop: 0,
23
+ addEventListener: () => {},
24
+ removeEventListener: () => {},
25
+ }
26
+ }
27
+
28
+ let app: App | null = null
29
+ let editor: Editor | null = null
30
+
31
+ async function flush() {
32
+ for (let i = 0; i < 5; i++) {
33
+ await nextTick()
34
+ }
35
+ }
36
+
37
+ async function openDialog(content: string, upload: TiptapifyUploadOptions | null = null, selectFirst = false): Promise<HTMLElement> {
38
+ editor = new Editor({
39
+ element: document.createElement('div'),
40
+ content,
41
+ extensions: [Document, Paragraph, Text, Youtube.configure({ nocookie: true }), Iframe, VideoFile, TiptapifyVideo],
42
+ })
43
+
44
+ app = createApp({
45
+ setup() {
46
+ provide('tiptapifyEditor', shallowRef(editor))
47
+ provide('tiptapifyI18n', { t: (key: string) => key })
48
+ provide(TIPTAPIFY_UPLOAD_KEY, ref(upload))
49
+
50
+ return () => h(VideoDialog)
51
+ },
52
+ })
53
+ app.use(createVuetify())
54
+ app.mount(document.body.appendChild(document.createElement('div')))
55
+ await flush()
56
+
57
+ if (selectFirst) {
58
+ editor.commands.setNodeSelection(0)
59
+ }
60
+ editor.commands.showTiptapifyVideo()
61
+ await flush()
62
+
63
+ return document.querySelector<HTMLElement>('.tiptapify-dialog')!
64
+ }
65
+
66
+ async function type(input: HTMLInputElement, value: string) {
67
+ input.value = value
68
+ input.dispatchEvent(new Event('input'))
69
+ await flush()
70
+ }
71
+
72
+ async function submit(card: HTMLElement) {
73
+ card.querySelector('form')!.requestSubmit()
74
+ await flush()
75
+ }
76
+
77
+ function buttonWithText(card: HTMLElement, text: string): HTMLButtonElement {
78
+ return [...card.querySelectorAll('button')].find(button => button.textContent?.includes(text)) as HTMLButtonElement
79
+ }
80
+
81
+ afterEach(() => {
82
+ vi.unstubAllGlobals()
83
+ app?.unmount()
84
+ editor?.destroy()
85
+ app = null
86
+ editor = null
87
+ document.body.innerHTML = ''
88
+ })
89
+
90
+ describe('video dialog', () => {
91
+ it('keeps the dialog open with an inline error for an unsupported link', async () => {
92
+ const card = await openDialog('<p></p>')
93
+
94
+ await type(card.querySelector('input')!, 'https://example.com/clip.html')
95
+ await submit(card)
96
+
97
+ expect(card.textContent).toContain('dialog.video.url_unsupported')
98
+ expect(document.querySelector('.tiptapify-dialog')).not.toBeNull()
99
+ expect(editor!.getHTML()).toBe('<p></p>')
100
+ })
101
+
102
+ it('inserts a responsive YouTube video starting at the link time', async () => {
103
+ const card = await openDialog('<p></p>')
104
+
105
+ await type(card.querySelector('input')!, 'https://youtu.be/dQw4w9WgXcQ?t=42')
106
+
107
+ expect(card.textContent).toContain('YouTube')
108
+ expect(card.querySelector<HTMLInputElement>('input[placeholder="0:00"]')!.value).toBe('0:42')
109
+
110
+ await submit(card)
111
+
112
+ const youtube = editor!.state.doc.firstChild!
113
+
114
+ expect(youtube.type.name).toBe('youtube')
115
+ expect(youtube.attrs).toMatchObject({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', start: 42, width: null, height: null })
116
+ })
117
+
118
+ it('keeps the custom size in its ratio until the lock is opened', async () => {
119
+ const card = await openDialog('<p></p>')
120
+ await type(card.querySelector('input')!, 'https://youtu.be/dQw4w9WgXcQ')
121
+ buttonWithText(card, 'dialog.video.size_custom').click()
122
+ await flush()
123
+
124
+ const [width, height] = card.querySelectorAll<HTMLInputElement>('input[type="number"]')
125
+ expect([width.value, height.value]).toEqual(['640', '360'])
126
+
127
+ await type(width, '1280')
128
+ expect(height.value).toBe('720')
129
+
130
+ card.querySelector<HTMLButtonElement>('[aria-label="dialog.video.keep_ratio"]')!.click()
131
+ await flush()
132
+ await type(width, '800')
133
+ expect(height.value).toBe('720')
134
+
135
+ await submit(card)
136
+ expect(editor!.getJSON().content!.find(node => node.type === 'youtube')!.attrs).toMatchObject({ width: 800, height: 720 })
137
+ })
138
+
139
+ it('inserts a link to a video file as a video node with the file options', async () => {
140
+ const card = await openDialog('<p></p>')
141
+ await type(card.querySelector('input')!, 'https://www.w3schools.com/tags/mov_bbb.mp4#t=5')
142
+
143
+ expect(card.querySelector('.tiptapify-video-dialog__preview video')!.getAttribute('src')).toBe('https://www.w3schools.com/tags/mov_bbb.mp4#t=5')
144
+ const loop = [...card.querySelectorAll('.v-checkbox')].find(box => box.textContent?.includes('dialog.video.loop'))!
145
+ loop.querySelector('input')!.click()
146
+ await flush()
147
+
148
+ await submit(card)
149
+
150
+ const video = editor!.getJSON().content!.find(node => node.type === 'video')!
151
+ expect(video.attrs).toMatchObject({ src: 'https://www.w3schools.com/tags/mov_bbb.mp4', start: 5, controls: true, loop: true })
152
+ })
153
+
154
+ it('opens a video linked by its file address on the link tab, even without the upload', async () => {
155
+ const card = await openDialog('<video src="https://www.w3schools.com/tags/mov_bbb.mp4#t=5" controls></video>', null, true)
156
+
157
+ expect(card.querySelector<HTMLInputElement>('input')!.value).toBe('https://www.w3schools.com/tags/mov_bbb.mp4')
158
+ expect(card.querySelector<HTMLInputElement>('input[placeholder="0:00"]')!.value).toBe('0:05')
159
+ })
160
+
161
+ it('offers the upload only when the editor has an upload endpoint', async () => {
162
+ const card = await openDialog('<p></p>')
163
+
164
+ expect(buttonWithText(card, 'dialog.video.source_upload')).toBeUndefined()
165
+ })
166
+
167
+ it('uploads a video file to the endpoint and inserts it as a video node', async () => {
168
+ const requests = fakeUploadEndpoint(() => ({ status: 201, body: { url: 'https://cdn.example.com/clip.mp4' } }))
169
+ const card = await openDialog('<p></p>', { endpoint: '/api/uploads' })
170
+
171
+ buttonWithText(card, 'dialog.video.source_upload').click()
172
+ await flush()
173
+
174
+ const input = card.querySelector<HTMLInputElement>('input[accept="video/*"]')!
175
+ Object.defineProperty(input, 'files', { value: [new File(['x'], 'clip.mp4', { type: 'video/mp4' })] })
176
+ input.dispatchEvent(new Event('change'))
177
+ await new Promise(resolve => setTimeout(resolve))
178
+ await flush()
179
+
180
+ expect(requests).toHaveLength(1)
181
+ expect(requests[0].body.get('kind')).toBe('video')
182
+ expect(card.textContent).toContain('clip.mp4')
183
+
184
+ await submit(card)
185
+
186
+ expect(editor!.state.doc.firstChild!.type.name).toBe('video')
187
+ expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: 'https://cdn.example.com/clip.mp4', controls: true, width: null })
188
+ })
189
+
190
+ it('shows the error the upload endpoint answers and inserts nothing', async () => {
191
+ fakeUploadEndpoint(() => ({ status: 413, body: { error: 'The file is too large.' } }))
192
+ const card = await openDialog('<p></p>', { endpoint: '/api/uploads' })
193
+
194
+ buttonWithText(card, 'dialog.video.source_upload').click()
195
+ await flush()
196
+
197
+ const input = card.querySelector<HTMLInputElement>('input[accept="video/*"]')!
198
+ Object.defineProperty(input, 'files', { value: [new File(['x'], 'clip.mp4', { type: 'video/mp4' })] })
199
+ input.dispatchEvent(new Event('change'))
200
+ await new Promise(resolve => setTimeout(resolve))
201
+ await flush()
202
+
203
+ expect(card.querySelector('[role="alert"]')!.textContent).toContain('The file is too large.')
204
+
205
+ await submit(card)
206
+
207
+ expect(editor!.getHTML()).toBe('<p></p>')
208
+ })
209
+
210
+ it('edits the selected video in place, keeping the attributes it does not know', async () => {
211
+ const card = await openDialog('<video src="/a.mp4" controls></video>', null, true)
212
+
213
+ expect(card.textContent).toContain('dialog.video.title_edit')
214
+ expect(card.querySelector<HTMLInputElement>('input')!.value).toBe('/a.mp4')
215
+
216
+ const loop = [...card.querySelectorAll('.v-checkbox')].find(box => box.textContent?.includes('dialog.video.loop'))!
217
+ loop.querySelector('input')!.click()
218
+ await flush()
219
+ await submit(card)
220
+
221
+ expect(editor!.state.doc.childCount).toBe(1)
222
+ expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: '/a.mp4', controls: true, loop: true })
223
+ })
224
+
225
+ it('removes the selected video', async () => {
226
+ const card = await openDialog('<video src="/a.mp4"></video><p>Text</p>', null, true)
227
+
228
+ buttonWithText(card, 'dialog.remove').click()
229
+ await flush()
230
+
231
+ expect(editor!.getHTML()).toBe('<p>Text</p>')
232
+ })
233
+ })