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.
- 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 +37 -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 +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -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 +10 -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,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]:
|
|
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
|
-
|
|
55
|
-
default:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|