tiptapify 0.3.5 → 0.4.1

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 +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -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 +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -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 +10 -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 +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ iframeStyle,
4
+ parsedFields,
5
+ parseEmbedInput,
6
+ pixelSize,
7
+ sizeFromAttrs,
8
+ sizeToAttrs,
9
+ srcError,
10
+ } from '@tiptapify/extensions/components/media/iframe/embed'
11
+
12
+ describe('embed input', () => {
13
+ it('takes a plain address as it is', () => {
14
+ expect(parseEmbedInput(' https://example.com/embed ')).toEqual({ kind: 'url', src: 'https://example.com/embed' })
15
+ expect(parseEmbedInput(' ')).toEqual({ kind: 'empty' })
16
+ })
17
+
18
+ it('reads a pasted iframe code', () => {
19
+ const code = `<iframe src="https://www.google.com/maps/embed?pb=1" width="600" height="450" style="border:0;"
20
+ allowfullscreen="" loading="lazy" title="Office map" allow="geolocation" referrerpolicy="no-referrer"></iframe>`
21
+
22
+ expect(parseEmbedInput(code)).toEqual({
23
+ kind: 'code',
24
+ iframe: {
25
+ src: 'https://www.google.com/maps/embed?pb=1',
26
+ width: 600,
27
+ height: 450,
28
+ title: 'Office map',
29
+ allow: 'geolocation',
30
+ loading: 'lazy',
31
+ allowfullscreen: true,
32
+ border: false,
33
+ },
34
+ })
35
+ })
36
+
37
+ it('reads the deprecated frameborder and an empty sandbox', () => {
38
+ const input = parseEmbedInput('<iframe src="https://a.example/x" frameborder="1" sandbox name="frame"></iframe>')
39
+
40
+ expect(input).toMatchObject({ kind: 'code', iframe: { border: true, sandbox: '', name: 'frame', allowfullscreen: false } })
41
+ })
42
+
43
+ it('takes the iframe out of a responsive wrapper box', () => {
44
+ const code = '<div style="position:relative;padding-bottom:56%"><iframe src="https://www.loom.com/embed/1"></iframe></div>'
45
+
46
+ expect(parseEmbedInput(code)).toMatchObject({ kind: 'code', iframe: { src: 'https://www.loom.com/embed/1' } })
47
+ })
48
+
49
+ it('ignores a size in percent', () => {
50
+ const input = parseEmbedInput('<iframe src="https://a.example/x" width="100%" height="450"></iframe>')
51
+
52
+ expect(input.kind === 'code' && input.iframe.width).toBeUndefined()
53
+ expect(pixelSize('100%')).toBeNull()
54
+ expect(pixelSize('640px')).toBe(640)
55
+ })
56
+
57
+ it.each([
58
+ ['a script', '<iframe src="https://a.example/x"></iframe><script src="https://a.example/x.js"></script>'],
59
+ ['a leading script', '<script>alert(1)</script><iframe src="https://a.example/x"></iframe>'],
60
+ ['two iframes', '<iframe src="https://a.example/x"></iframe><iframe src="https://a.example/y"></iframe>'],
61
+ ['text around', '<iframe src="https://a.example/x"></iframe><p>By <a href="https://a.example">A</a></p>'],
62
+ ['no iframe', '<blockquote class="twitter-tweet"></blockquote>'],
63
+ ['no address', '<iframe width="600"></iframe>'],
64
+ ])('rejects a code with %s', (_, code) => {
65
+ expect(parseEmbedInput(code)).toEqual({ kind: 'invalid_code' })
66
+ })
67
+
68
+ it('lists what the code filled', () => {
69
+ const fields = parsedFields({ src: 'https://a.example/x', width: 600, height: 450, allowfullscreen: true, sandbox: '' })
70
+
71
+ expect(fields).toEqual([
72
+ { key: 'parsed_src' },
73
+ { key: 'parsed_size', params: { width: 600, height: 450 } },
74
+ { key: 'parsed_sandbox' },
75
+ { key: 'parsed_fullscreen' },
76
+ ])
77
+ })
78
+ })
79
+
80
+ describe('embed address', () => {
81
+ it('needs a web address with its protocol', () => {
82
+ expect(srcError('https://example.com/embed?a=1')).toBeNull()
83
+ expect(srcError('http://intranet/embed')).toBeNull()
84
+ expect(srcError('')).toBe('required')
85
+ })
86
+
87
+ it.each(['example.com/embed', '//example.com/embed', 'localhost:8080/embed'])('asks for the protocol of %s', (src) => {
88
+ expect(srcError(src)).toBe('protocol')
89
+ })
90
+
91
+ it.each(['javascript:alert(1)', 'mailto:a@b.c', '/embed/page', 'ftp://example.com/file', 'https://exa mple.com'])(
92
+ 'rejects %s',
93
+ (src) => {
94
+ expect(srcError(src)).toBe('invalid')
95
+ },
96
+ )
97
+ })
98
+
99
+ describe('embed size', () => {
100
+ it('stores each size mode and reads it back', () => {
101
+ const known = { width: 600, height: 450 }
102
+
103
+ for (const mode of ['responsive', '16:9', '4:3', '1:1'] as const) {
104
+ expect(sizeFromAttrs(sizeToAttrs(mode, { width: null, height: null }, known)).mode).toBe(mode)
105
+ }
106
+
107
+ expect(sizeFromAttrs(sizeToAttrs('custom', { width: 800, height: 600 }, known))).toEqual({ mode: 'custom', width: 800, height: 600 })
108
+ expect(sizeToAttrs('responsive', { width: null, height: null }, known)).toEqual({ ratio: 'auto', width: 600, height: 450 })
109
+ })
110
+
111
+ it('reads old content with a width and a height as custom, without a size as responsive', () => {
112
+ expect(sizeFromAttrs({ ratio: null, width: '640', height: 360 } as never)).toEqual({ mode: 'custom', width: 640, height: 360 })
113
+ expect(sizeFromAttrs({ ratio: null, width: null, height: null }).mode).toBe('responsive')
114
+ })
115
+
116
+ it('renders the proportions and the border as a style', () => {
117
+ expect(iframeStyle({ ratio: 'auto', width: 600, height: 450, border: false }))
118
+ .toBe('width: 100%; height: auto; aspect-ratio: 600 / 450; border: 0;')
119
+ expect(iframeStyle({ ratio: 'auto', width: null, height: null, border: false })).toContain('aspect-ratio: 16 / 9')
120
+ expect(iframeStyle({ ratio: '4:3', width: null, height: null, border: false })).toContain('aspect-ratio: 4 / 3')
121
+ expect(iframeStyle({ ratio: null, width: 640, height: 360, border: true })).toBe('border: 1px solid rgba(128, 128, 128, 0.5);')
122
+ })
123
+ })
@@ -0,0 +1,236 @@
1
+ import { MediaSize, toSize } from '@tiptapify/utils/mediaSize'
2
+ import { isValidHref } from '@tiptapify/utils/validateHref'
3
+
4
+ export const ratioPresets = ['16:9', '4:3', '1:1'] as const
5
+
6
+ export type IframeSizeMode = 'responsive' | 'custom' | typeof ratioPresets[number]
7
+
8
+ /**
9
+ * The iframe node attributes. `ratio` makes the iframe fill the text width: a preset (`16:9`) or `auto`
10
+ * (responsive: the proportions of its width and height, else 16:9); without it the width and height are its size.
11
+ */
12
+ export type IframeAttrs = {
13
+ src: string
14
+ title: string | null
15
+ name: string | null
16
+ allow: string | null
17
+ sandbox: string | null
18
+ loading: string | null
19
+ allowfullscreen: boolean
20
+ border: boolean
21
+ ratio: string | null
22
+ width: number | null
23
+ height: number | null
24
+ }
25
+
26
+ // The attributes a pasted <iframe> code has
27
+ export type ParsedIframe = Partial<IframeAttrs> & { src: string }
28
+
29
+ export type EmbedInput =
30
+ | { kind: 'empty' }
31
+ | { kind: 'url', src: string }
32
+ | { kind: 'code', iframe: ParsedIframe }
33
+ | { kind: 'invalid_code' }
34
+
35
+ export type SrcError = 'required' | 'protocol' | 'invalid'
36
+
37
+ export type IframeSize = { mode: IframeSizeMode, width: number | null, height: number | null }
38
+
39
+ // Elements an embed code may wrap its iframe in (a responsive box); anything else is not a single iframe
40
+ const wrapperTags = new Set(['DIV', 'P', 'SPAN', 'FIGURE'])
41
+
42
+ const ratioRegex = /^(auto|\d+:\d+)$/
43
+
44
+ const borderStyle = 'border: 1px solid rgba(128, 128, 128, 0.5)'
45
+
46
+ // A size in pixels (`600`, `600px`); not a percentage, which `toSize` would read as pixels
47
+ export function pixelSize(value: unknown): number | null {
48
+ return typeof value === 'number' || /^\s*\d+(\.\d+)?(px)?\s*$/i.test(String(value ?? '')) ? toSize(value) : null
49
+ }
50
+
51
+ export function parseRatio(value: string | null): string | null {
52
+ return value && ratioRegex.test(value.trim()) ? value.trim() : null
53
+ }
54
+
55
+ // Whether the iframe shows a border: by its style, else by the deprecated `frameborder`; null when neither says
56
+ export function readBorder(element: HTMLElement): boolean | null {
57
+ const { border, borderStyle, borderWidth } = element.style
58
+ if (border || borderStyle || borderWidth) {
59
+ return !['none', 'hidden'].includes(borderStyle) && parseFloat(borderWidth || border) !== 0 && border !== 'none'
60
+ }
61
+
62
+ const frameborder = element.getAttribute('frameborder')
63
+ if (frameborder !== null) {
64
+ return !['0', 'no'].includes(frameborder.trim().toLowerCase())
65
+ }
66
+
67
+ return null
68
+ }
69
+
70
+ function readText(element: Element, name: string): string | undefined {
71
+ return element.getAttribute(name)?.trim() || undefined
72
+ }
73
+
74
+ function readIframe(element: HTMLIFrameElement): ParsedIframe {
75
+ const iframe: ParsedIframe = { src: readText(element, 'src') ?? '' }
76
+ const width = pixelSize(element.getAttribute('width'))
77
+ const height = pixelSize(element.getAttribute('height'))
78
+ if (width && height) {
79
+ Object.assign(iframe, { width, height })
80
+ }
81
+
82
+ for (const name of ['title', 'name', 'allow', 'loading'] as const) {
83
+ const value = readText(element, name)
84
+ if (value) {
85
+ iframe[name] = value
86
+ }
87
+ }
88
+
89
+ // An empty sandbox is the strictest one, not a missing one
90
+ if (element.hasAttribute('sandbox')) {
91
+ iframe.sandbox = element.getAttribute('sandbox')!.trim()
92
+ }
93
+
94
+ iframe.allowfullscreen = element.hasAttribute('allowfullscreen')
95
+
96
+ const border = readBorder(element)
97
+ if (border !== null) {
98
+ iframe.border = border
99
+ }
100
+
101
+ return iframe
102
+ }
103
+
104
+ // The iframe of an embed code that is a single iframe with an address (maybe in wrapper boxes), else null
105
+ function singleIframe(code: string): HTMLIFrameElement | null {
106
+ // A parsed document is inert: its scripts do not run and nothing loads
107
+ const doc = new DOMParser().parseFromString(code, 'text/html')
108
+ const iframes = doc.body.querySelectorAll('iframe')
109
+ if (iframes.length !== 1 || doc.head.childElementCount > 0) {
110
+ return null
111
+ }
112
+
113
+ const iframe = iframes[0]
114
+ const others = [...doc.body.querySelectorAll('*')].filter(element => element !== iframe)
115
+ if (others.some(element => !wrapperTags.has(element.tagName) || !element.contains(iframe))) {
116
+ return null
117
+ }
118
+
119
+ iframe.remove()
120
+
121
+ return doc.body.textContent!.trim() === '' && readText(iframe, 'src') ? iframe : null
122
+ }
123
+
124
+ /**
125
+ * The "Link or embed code" field: an address, or a pasted `<iframe …>` code read into its attributes.
126
+ */
127
+ export function parseEmbedInput(value: string): EmbedInput {
128
+ const text = value.trim()
129
+ if (!text) {
130
+ return { kind: 'empty' }
131
+ }
132
+
133
+ if (!text.startsWith('<')) {
134
+ return { kind: 'url', src: text }
135
+ }
136
+
137
+ const iframe = singleIframe(text)
138
+
139
+ return iframe ? { kind: 'code', iframe: readIframe(iframe) } : { kind: 'invalid_code' }
140
+ }
141
+
142
+ /**
143
+ * An iframe needs a web address with its protocol, which is never guessed: an address without one
144
+ * (`example.com/embed`, `//example.com/embed`) asks for it.
145
+ */
146
+ export function srcError(src: string): SrcError | null {
147
+ if (!src) {
148
+ return 'required'
149
+ }
150
+
151
+ if (/^https?:\/\//i.test(src) && isValidHref(src)) {
152
+ return null
153
+ }
154
+
155
+ const bare = src.replace(/^\/\//, '')
156
+ // A scheme, not a host and a port (`localhost:8080`)
157
+ const hasScheme = /^[a-z][a-z0-9+.-]*:(?!\d)/i.test(bare)
158
+
159
+ return !hasScheme && isValidHref(`https://${bare}`) ? 'protocol' : 'invalid'
160
+ }
161
+
162
+ // The fields a parsed code filled, for the line under the field
163
+ export function parsedFields(iframe: ParsedIframe): Array<{ key: string, params?: Record<string, number> }> {
164
+ const fields: Array<{ key: string, params?: Record<string, number> }> = [{ key: 'parsed_src' }]
165
+
166
+ if (iframe.width && iframe.height) {
167
+ fields.push({ key: 'parsed_size', params: { width: iframe.width, height: iframe.height } })
168
+ }
169
+
170
+ for (const name of ['title', 'name', 'allow', 'sandbox'] as const) {
171
+ if (iframe[name] !== undefined) {
172
+ fields.push({ key: `parsed_${name}` })
173
+ }
174
+ }
175
+
176
+ if (iframe.loading === 'lazy') {
177
+ fields.push({ key: 'parsed_loading' })
178
+ }
179
+
180
+ if (iframe.allowfullscreen) {
181
+ fields.push({ key: 'parsed_fullscreen' })
182
+ }
183
+
184
+ return fields
185
+ }
186
+
187
+ /**
188
+ * The dialog's size of a node: a ratio preset, responsive (its size gives the proportions) or custom.
189
+ * A node with only a width and a height is custom; without a size it is responsive.
190
+ */
191
+ export function sizeFromAttrs({ ratio, width, height }: Pick<IframeAttrs, 'ratio' | 'width' | 'height'>): IframeSize {
192
+ const size = { width: pixelSize(width), height: pixelSize(height) }
193
+
194
+ if (ratioPresets.includes(ratio as typeof ratioPresets[number])) {
195
+ return { mode: ratio as IframeSizeMode, ...size }
196
+ }
197
+
198
+ return { mode: !ratio && size.width ? 'custom' : 'responsive', ...size }
199
+ }
200
+
201
+ // The node attributes of a size; responsive keeps the known size, for its proportions
202
+ export function sizeToAttrs(mode: IframeSizeMode, size: MediaSize, known: MediaSize): Pick<IframeAttrs, 'ratio' | 'width' | 'height'> {
203
+ if (mode === 'custom') {
204
+ return { ratio: null, ...size }
205
+ }
206
+
207
+ if (mode === 'responsive') {
208
+ return known.width && known.height ? { ratio: 'auto', ...known } : { ratio: 'auto', width: null, height: null }
209
+ }
210
+
211
+ return { ratio: mode, width: null, height: null }
212
+ }
213
+
214
+ // The proportions a responsive or preset size renders in (`16 / 9`), null for a custom size
215
+ export function aspectRatio({ ratio, width, height }: Pick<IframeAttrs, 'ratio' | 'width' | 'height'>): string | null {
216
+ if (ratio !== 'auto') {
217
+ return ratio ? ratio.replace(':', ' / ') : null
218
+ }
219
+
220
+ const known = { width: pixelSize(width), height: pixelSize(height) }
221
+
222
+ return known.width && known.height ? `${known.width} / ${known.height}` : '16 / 9'
223
+ }
224
+
225
+ /**
226
+ * The inline style of the iframe, so a site shows it without the editor's styles or scripts: the
227
+ * proportions of a size filling the text width, and the border (instead of the deprecated `frameborder`).
228
+ */
229
+ export function iframeStyle(attrs: Pick<IframeAttrs, 'ratio' | 'width' | 'height' | 'border'>): string {
230
+ const aspect = aspectRatio(attrs)
231
+ const rules = aspect ? ['width: 100%', 'height: auto', `aspect-ratio: ${aspect}`] : []
232
+
233
+ rules.push(attrs.border ? borderStyle : 'border: 0')
234
+
235
+ return `${rules.join('; ')};`
236
+ }
@@ -0,0 +1,123 @@
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 Iframe, { selectedIframe } from '@tiptapify/extensions/components/media/iframe'
7
+ import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
8
+
9
+ let editor: Editor | null = null
10
+
11
+ function createEditor(content: string) {
12
+ editor = new Editor({
13
+ element: document.createElement('div'),
14
+ content,
15
+ extensions: [Document, Paragraph, Text, Iframe, TiptapifyVideo],
16
+ })
17
+
18
+ return editor
19
+ }
20
+
21
+ function iframeAttrs() {
22
+ return editor!.state.doc.firstChild!.attrs
23
+ }
24
+
25
+ afterEach(() => {
26
+ editor?.destroy()
27
+ editor = null
28
+ })
29
+
30
+ describe('iframe node', () => {
31
+ it('renders a responsive embed that a site shows without the editor', () => {
32
+ createEditor('<p></p>')
33
+ editor!.commands.setIframe({ src: 'https://a.example/x', title: 'Map', ratio: 'auto', width: 600, height: 450, loading: 'lazy' })
34
+
35
+ const html = editor!.getHTML()
36
+ expect(html).toContain('<div class="iframe-wrapper"><iframe')
37
+ expect(html).toContain('data-ratio="auto"')
38
+ // jsdom writes the style back normalized (`0px`)
39
+ expect(html).toMatch(/style="width: 100%; height: auto; aspect-ratio: 600 \/ 450; border: 0(px)?;"/)
40
+ expect(html).toContain('loading="lazy"')
41
+ expect(html).not.toContain('frameborder')
42
+ })
43
+
44
+ it.each([
45
+ ['responsive', { ratio: 'auto', width: 600, height: 450 }],
46
+ ['a ratio preset', { ratio: '4:3', width: null, height: null }],
47
+ ['a custom size with a border', { ratio: null, width: 640, height: 360, border: true }],
48
+ ['the advanced options', { allow: 'autoplay; fullscreen', sandbox: '', name: 'frame', loading: 'lazy', allowfullscreen: true }],
49
+ ])('reads back %s from its HTML', (_, attrs) => {
50
+ createEditor('<p></p>')
51
+ editor!.commands.setIframe({ src: 'https://a.example/x', ...attrs })
52
+ const stored = { ...iframeAttrs() }
53
+
54
+ editor!.commands.setContent(editor!.getHTML())
55
+
56
+ expect(iframeAttrs()).toEqual(stored)
57
+ })
58
+
59
+ it('keeps fullscreen of old embeds saved without the attribute, as before', () => {
60
+ createEditor('<iframe src="https://a.example/x" width="640" height="360"></iframe>')
61
+
62
+ expect(iframeAttrs().allowfullscreen).toBe(true)
63
+ })
64
+
65
+ it('loads old content: the size as custom, frameborder as the border', () => {
66
+ createEditor('<iframe src="https://a.example/x" width="640" height="360" frameborder="1" allowfullscreen="true"></iframe>')
67
+
68
+ expect(iframeAttrs()).toMatchObject({ width: 640, height: 360, ratio: null, border: true, allowfullscreen: true })
69
+ expect(editor!.getHTML()).toContain('style="border: 1px solid rgba(128, 128, 128, 0.5);"')
70
+ })
71
+ })
72
+
73
+ describe('an embed block in the editor', () => {
74
+ function doubleClickNode(pos: number) {
75
+ const { view } = editor!
76
+ const node = view.state.doc.nodeAt(pos)!
77
+
78
+ return view.someProp('handleDoubleClickOn', handle => handle(view, pos, node, pos, new MouseEvent('dblclick'), true))
79
+ }
80
+
81
+ function listen(name: string) {
82
+ const listener = vi.fn()
83
+ window.addEventListener(name, listener)
84
+
85
+ return { listener, stop: () => window.removeEventListener(name, listener) }
86
+ }
87
+
88
+ it('opens the embed dialog for the iframe on double click', () => {
89
+ createEditor('<iframe src="https://maps.example.com/embed" title="Map"></iframe>')
90
+ const { listener, stop } = listen('tiptapify-show-iframe')
91
+
92
+ expect(doubleClickNode(0)).toBe(true)
93
+ stop()
94
+
95
+ expect(selectedIframe(editor!)).toMatchObject({ src: 'https://maps.example.com/embed', title: 'Map' })
96
+ expect(listener).toHaveBeenCalledOnce()
97
+ expect(listener.mock.calls[0][0].detail.node).toMatchObject({ src: 'https://maps.example.com/embed' })
98
+ })
99
+
100
+ it('leaves a Vimeo player to the video dialog', () => {
101
+ createEditor('<iframe src="https://player.vimeo.com/video/76979871"></iframe>')
102
+ const iframe = listen('tiptapify-show-iframe')
103
+ const video = listen('tiptapify-show-tiptapifyVideo')
104
+
105
+ doubleClickNode(0)
106
+ iframe.stop()
107
+ video.stop()
108
+
109
+ expect(iframe.listener).not.toHaveBeenCalled()
110
+ expect(video.listener).toHaveBeenCalledOnce()
111
+ expect(selectedIframe(editor!)).toBeNull()
112
+ })
113
+
114
+ it('opens the dialog to insert when no embed is selected', () => {
115
+ createEditor('<p>Text</p>')
116
+ const { listener, stop } = listen('tiptapify-show-iframe')
117
+
118
+ editor!.commands.showIframe()
119
+ stop()
120
+
121
+ expect(listener.mock.calls[0][0].detail.node).toBeNull()
122
+ })
123
+ })
@@ -1,9 +1,12 @@
1
- import { Node } from '@tiptap/core'
1
+ import { Editor, Node } from '@tiptap/core'
2
+ import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
3
+ import { IframeAttrs, iframeStyle, parseRatio, readBorder } from '@tiptapify/extensions/components/media/iframe/embed'
4
+ import { parseVideoUrl } from '@tiptapify/extensions/components/media/video/providers'
2
5
 
3
6
  export interface IframeOptions {
4
7
  allowFullscreen: boolean
5
8
  HTMLAttributes: {
6
- [key: string]: any
9
+ [key: string]: unknown
7
10
  }
8
11
  }
9
12
 
@@ -13,11 +16,33 @@ declare module '@tiptap/core' {
13
16
  /**
14
17
  * Add an iframe
15
18
  */
16
- setIframe: (options: { src: string }) => ReturnType
19
+ setIframe: (options: Partial<IframeAttrs> & { src: string }) => ReturnType
20
+ /**
21
+ * Open the embed dialog: for the selected iframe, else to insert one
22
+ */
23
+ showIframe: () => ReturnType
17
24
  }
18
25
  }
19
26
  }
20
27
 
28
+ // A Vimeo player is stored as an iframe too, but the video dialog edits it
29
+ export function isVideoIframe(src: unknown): boolean {
30
+ return parseVideoUrl(String(src ?? ''))?.provider.nodeType === 'iframe'
31
+ }
32
+
33
+ /**
34
+ * The attributes of the selected iframe the embed dialog edits (not a Vimeo player); null when none is selected.
35
+ */
36
+ export function selectedIframe(editor: Editor): IframeAttrs | null {
37
+ const { selection } = editor.state
38
+
39
+ if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'iframe' || isVideoIframe(selection.node.attrs.src)) {
40
+ return null
41
+ }
42
+
43
+ return { ...selection.node.attrs } as IframeAttrs
44
+ }
45
+
21
46
  export default Node.create<IframeOptions>({
22
47
  name: 'iframe',
23
48
 
@@ -51,12 +76,31 @@ export default Node.create<IframeOptions>({
51
76
  height: {
52
77
  default: null,
53
78
  },
54
- frameborder: {
55
- default: 0,
79
+ allow: {
80
+ default: null,
81
+ },
82
+ sandbox: {
83
+ default: null,
84
+ },
85
+ loading: {
86
+ default: null,
56
87
  },
57
88
  allowfullscreen: {
58
89
  default: this.options.allowFullscreen,
59
- parseHTML: () => this.options.allowFullscreen,
90
+ // Embeds get fullscreen unless the editor turns it off (`allowFullscreen: false`)
91
+ parseHTML: element => element.hasAttribute('allowfullscreen') || this.options.allowFullscreen,
92
+ renderHTML: ({ allowfullscreen }) => (allowfullscreen ? { allowfullscreen: 'true' } : {}),
93
+ },
94
+ // The border and the ratio render as the iframe style (see renderHTML)
95
+ border: {
96
+ default: false,
97
+ parseHTML: element => readBorder(element) ?? false,
98
+ rendered: false,
99
+ },
100
+ ratio: {
101
+ default: null,
102
+ parseHTML: element => parseRatio(element.getAttribute('data-ratio')),
103
+ renderHTML: ({ ratio }) => (ratio ? { 'data-ratio': ratio } : {}),
60
104
  },
61
105
  }
62
106
  },
@@ -69,14 +113,16 @@ export default Node.create<IframeOptions>({
69
113
  ]
70
114
  },
71
115
 
72
- renderHTML({ HTMLAttributes }) {
73
- return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
116
+ renderHTML({ node, HTMLAttributes }) {
117
+ const style = iframeStyle(node.attrs as IframeAttrs)
118
+
119
+ return ['div', this.options.HTMLAttributes, ['iframe', { ...HTMLAttributes, style }]]
74
120
  },
75
121
 
76
122
  addCommands() {
77
123
  return {
78
124
  setIframe:
79
- (options: { src: string }) =>
125
+ options =>
80
126
  ({ tr, dispatch }) => {
81
127
  const { selection } = tr
82
128
  const node = this.type.create(options)
@@ -87,6 +133,42 @@ export default Node.create<IframeOptions>({
87
133
 
88
134
  return true
89
135
  },
136
+ showIframe: () => ({ editor }) => {
137
+ const event = new CustomEvent('tiptapify-show-iframe', {
138
+ detail: {
139
+ node: selectedIframe(editor),
140
+ editorId: editor.instanceId,
141
+ },
142
+ })
143
+
144
+ window.dispatchEvent(event)
145
+
146
+ return true
147
+ },
90
148
  }
91
149
  },
92
- })
150
+
151
+ // While editing, an iframe takes no clicks (see the editor styles): a click selects its block, and a
152
+ // double click opens the embed dialog for it. A Vimeo player opens the video dialog instead.
153
+ addProseMirrorPlugins() {
154
+ const { editor } = this
155
+
156
+ return [
157
+ new Plugin({
158
+ key: new PluginKey('iframe'),
159
+ props: {
160
+ handleDoubleClickOn: (_view, _pos, node, nodePos) => {
161
+ if (node.type.name !== 'iframe' || isVideoIframe(node.attrs.src)) {
162
+ return false
163
+ }
164
+
165
+ editor.commands.setNodeSelection(nodePos)
166
+ editor.commands.showIframe()
167
+
168
+ return true
169
+ },
170
+ },
171
+ }),
172
+ ]
173
+ },
174
+ })