tiptapify 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +34 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
- package/src/extensions/components/media/image/imageDialog.ts +114 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +8 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- package/src/utils/validateHref.ts +47 -7
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
|
5
5
|
import { TiptapifyFooterAlignment } from '@tiptapify/types/editor'
|
|
6
6
|
import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
|
|
7
7
|
import { registerSkin, defineSkin } from '@tiptapify/skins/registry'
|
|
8
|
+
import { contentCss, inlineContentStyles } from '@tiptapify/utils/contentStyles'
|
|
8
9
|
|
|
9
10
|
import { Editor as TipTapEditor, nodeViewProps, NodeViewWrapper, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
10
11
|
import { Node, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
|
|
@@ -52,9 +53,12 @@ export {
|
|
|
52
53
|
mergeAttributes,
|
|
53
54
|
registerSkin,
|
|
54
55
|
defineSkin,
|
|
56
|
+
contentCss,
|
|
57
|
+
inlineContentStyles,
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export type { TiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
61
|
+
export type { TiptapifyContentStyles } from '@tiptapify/utils/contentStyles'
|
|
58
62
|
export type { TiptapifyFooterAlignment }
|
|
59
63
|
export type { CommandProps, InputRuleMatch, PasteRuleMatch }
|
|
60
64
|
export type {
|
|
@@ -65,6 +69,10 @@ export type {
|
|
|
65
69
|
TiptapifySkinConfig,
|
|
66
70
|
TiptapifySkinInput,
|
|
67
71
|
ResolvedShape,
|
|
72
|
+
TiptapifyLinkPreviewData,
|
|
73
|
+
TiptapifyLinkPreviewOptions,
|
|
74
|
+
TiptapifyUploadKind,
|
|
75
|
+
TiptapifyUploadOptions,
|
|
68
76
|
} from '@tiptapify/types/editor'
|
|
69
77
|
export type {
|
|
70
78
|
TiptapifyAiBackendProviderOptions,
|
|
@@ -88,7 +88,7 @@ describe('skin registry', () => {
|
|
|
88
88
|
expect(skin.className).toBe('tiptapify-skin-inline')
|
|
89
89
|
expect(skin.shape.bubble).toBe('flat')
|
|
90
90
|
expect(skin.tokens['--tf-bubble-radius']).toBe('2px')
|
|
91
|
-
expect(skin.tokens['--tf-bubble-
|
|
91
|
+
expect(skin.tokens['--tf-bubble-border']).toBe('1px solid rgba(var(--v-border-color), var(--v-border-opacity))')
|
|
92
92
|
})
|
|
93
93
|
|
|
94
94
|
it('ignores unknown token keys in dev with a warning', () => {
|
package/src/skins/tokens.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const baseColors: TiptapifySkinColors = {
|
|
|
33
33
|
buttonActiveHoverBg: 'rgb(var(--v-theme-primary))',
|
|
34
34
|
bubbleBg: 'rgb(var(--v-theme-surface))',
|
|
35
35
|
bubbleBorder: 'none',
|
|
36
|
-
bubbleShadow: '
|
|
36
|
+
bubbleShadow: '0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
|
37
37
|
menuBorder: 'none',
|
|
38
38
|
menuShadow: '0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12)',
|
|
39
39
|
menuItemActiveBg: 'rgba(0, 0, 0, 0)',
|
|
@@ -62,8 +62,8 @@ export const baseMetrics: TiptapifySkinMetrics = {
|
|
|
62
62
|
dividerHeight: '0px',
|
|
63
63
|
buttonRadius: '4px',
|
|
64
64
|
bubbleRadius: '6px',
|
|
65
|
-
bubblePadding: '
|
|
66
|
-
bubbleGap: '
|
|
65
|
+
bubblePadding: '4px',
|
|
66
|
+
bubbleGap: '2px',
|
|
67
67
|
menuListPadding: '8px 0px',
|
|
68
68
|
menuRadius: '4px',
|
|
69
69
|
menuItemRadius: '0px',
|
package/src/types/editor.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type variantFieldTypes = 'outlined' | 'plain' | 'filled' | 'solo' | 'solo
|
|
|
11
11
|
|
|
12
12
|
export type TiptapifyFooterAlignment = 'start' | 'center' | 'end'
|
|
13
13
|
|
|
14
|
-
// The skin of the editor chrome: 'default' is the classic look, 'pastel' is the quiet
|
|
14
|
+
// The skin of the editor chrome: 'default' is the classic look, 'pastel' is the quiet look
|
|
15
15
|
export type TiptapifySkin = 'default' | 'pastel'
|
|
16
16
|
|
|
17
17
|
// Structural variants a token cannot express (component markup, vuetify prop bindings).
|
|
@@ -127,6 +127,9 @@ export type TiptapifyAiChatMessage = {
|
|
|
127
127
|
export type TiptapifyAiEditorContext = {
|
|
128
128
|
prompt: string,
|
|
129
129
|
selectedText: string,
|
|
130
|
+
// The text of the selection's paragraph before and after it (empty without a selection)
|
|
131
|
+
textBefore: string,
|
|
132
|
+
textAfter: string,
|
|
130
133
|
text: string,
|
|
131
134
|
html: string,
|
|
132
135
|
json: object,
|
|
@@ -220,3 +223,39 @@ export type TiptapifyAiResolvedOptions = TiptapifyAiOptions & {
|
|
|
220
223
|
enabled: boolean,
|
|
221
224
|
promptExamples: TiptapifyAiPromptExample[],
|
|
222
225
|
}
|
|
226
|
+
|
|
227
|
+
export type TiptapifyUploadKind = 'image' | 'video'
|
|
228
|
+
|
|
229
|
+
// The upload endpoint: `POST {endpoint}` takes a file (multipart `file` and `kind`) and answers `{ url }`
|
|
230
|
+
export type TiptapifyUploadOptions = {
|
|
231
|
+
endpoint: string,
|
|
232
|
+
// Extra request headers, e.g. authorization
|
|
233
|
+
headers?: Record<string, string>,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type TiptapifyUploadContext = {
|
|
237
|
+
kind: TiptapifyUploadKind,
|
|
238
|
+
// Upload progress from 0 to 1
|
|
239
|
+
onProgress: (fraction: number) => void,
|
|
240
|
+
// Aborted when the user cancels the upload or closes the dialog
|
|
241
|
+
signal: AbortSignal,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Stores a picked file and gives its URL: the upload endpoint, else (an image only) a base64 data URL
|
|
245
|
+
export type TiptapifyUpload = (file: File, context: TiptapifyUploadContext) => Promise<{ url: string }>
|
|
246
|
+
|
|
247
|
+
// The backend endpoint of link cards: it reads the OpenGraph data of pages (a browser can't, CORS)
|
|
248
|
+
export type TiptapifyLinkPreviewOptions = {
|
|
249
|
+
// `GET {endpoint}` answers the handshake, `GET {endpoint}?url=<page>` the data of the page
|
|
250
|
+
endpoint: string,
|
|
251
|
+
// Extra request headers, e.g. authorization
|
|
252
|
+
headers?: Record<string, string>,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export type TiptapifyLinkPreviewData = {
|
|
256
|
+
url: string,
|
|
257
|
+
title?: string,
|
|
258
|
+
description?: string,
|
|
259
|
+
image?: string,
|
|
260
|
+
siteName?: string,
|
|
261
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
4
|
+
import { contentCss, contentHTML, inlineContentStyles } from '@tiptapify/utils/contentStyles'
|
|
5
|
+
|
|
6
|
+
let editor: Editor | null = null
|
|
7
|
+
|
|
8
|
+
function editorWith(content: string): Editor {
|
|
9
|
+
editor = new Editor({ extensions: editorExtensions('', false, []), content })
|
|
10
|
+
|
|
11
|
+
return editor
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
editor?.destroy()
|
|
16
|
+
editor = null
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function element(html: string, selector: string): HTMLElement {
|
|
20
|
+
const template = document.createElement('template')
|
|
21
|
+
template.innerHTML = html
|
|
22
|
+
|
|
23
|
+
return template.content.querySelector<HTMLElement>(selector)!
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const card = (layout: string) => `<a class="tiptapify-link-card" data-link-card="${layout}" href="https://example.com">`
|
|
27
|
+
+ '<img class="tiptapify-link-card__image" src="/i.png" alt=""><span class="tiptapify-link-card__body">'
|
|
28
|
+
+ '<span class="tiptapify-link-card__site">Example</span><span class="tiptapify-link-card__title">Title</span>'
|
|
29
|
+
+ '<span class="tiptapify-link-card__description">Text</span></span></a>'
|
|
30
|
+
|
|
31
|
+
describe('content styles', () => {
|
|
32
|
+
it('make a stylesheet for a site, weightless under :where(), and one for the editor', () => {
|
|
33
|
+
expect(contentCss()).toContain(':where(.tiptapify-content) .tiptapify-link-card { display: flex;')
|
|
34
|
+
expect(contentCss('.tiptap', { weightless: false })).toContain('.tiptap .tiptapify-link-card { display: flex;')
|
|
35
|
+
expect(contentCss('.tiptap', { weightless: false })).not.toContain(':where(')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('write the look of a link card into its elements, per layout', () => {
|
|
39
|
+
const compact = inlineContentStyles(card('compact'))
|
|
40
|
+
expect(element(compact, 'a').style.display).toBe('flex')
|
|
41
|
+
expect(element(compact, 'a').style.flexWrap).toBe('wrap')
|
|
42
|
+
expect(element(compact, 'a').style.color).toBe('inherit')
|
|
43
|
+
expect(element(compact, 'img').style.objectFit).toBe('cover')
|
|
44
|
+
expect(element(compact, '.tiptapify-link-card__title').style.fontWeight).toBe('600')
|
|
45
|
+
expect(element(compact, '.tiptapify-link-card__description').getAttribute('style')).toContain('-webkit-line-clamp: 3;')
|
|
46
|
+
|
|
47
|
+
const large = inlineContentStyles(card('large'))
|
|
48
|
+
expect(element(large, 'a').style.flexDirection).toBe('column')
|
|
49
|
+
expect(element(large, 'a').style.maxWidth).toBe('520px')
|
|
50
|
+
expect(element(large, 'img').getAttribute('style')).toContain('aspect-ratio: 1.91 / 1;')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keep the styles an element has and change nothing when applied twice', () => {
|
|
54
|
+
const html = '<figure data-align="left"><img src="/a.png" style="float: left;"><figcaption>Caption</figcaption></figure>'
|
|
55
|
+
const once = inlineContentStyles(html)
|
|
56
|
+
|
|
57
|
+
expect(element(once, 'img').style.float).toBe('left')
|
|
58
|
+
expect(element(once, 'img').style.display).toBe('block')
|
|
59
|
+
expect(element(once, 'figure').style.float).toBe('left')
|
|
60
|
+
expect(element(once, 'figure').style.maxWidth).toBe('50%')
|
|
61
|
+
expect(element(once, 'figcaption').style.textAlign).toBe('start')
|
|
62
|
+
expect(inlineContentStyles(once)).toBe(once)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('size videos and embeds, and lay out task lists', () => {
|
|
66
|
+
const html = '<video src="/a.mp4"></video><video src="/b.mp4" width="320"></video>'
|
|
67
|
+
+ '<div data-youtube-video=""><iframe src="https://www.youtube-nocookie.com/embed/x"></iframe></div>'
|
|
68
|
+
+ '<ul data-type="taskList"><li data-type="taskItem"><label><input type="checkbox"></label><div><p>Task</p></div></li></ul>'
|
|
69
|
+
+ '<p>Text</p>'
|
|
70
|
+
const styled = inlineContentStyles(html)
|
|
71
|
+
|
|
72
|
+
expect(element(styled, 'video[src="/a.mp4"]').getAttribute('style')).toContain('aspect-ratio: 16 / 9;')
|
|
73
|
+
expect(element(styled, 'video[src="/b.mp4"]').getAttribute('style')).toBe('display: block; max-width: 100%;')
|
|
74
|
+
expect(element(styled, 'iframe').getAttribute('style')).toContain('aspect-ratio: 16 / 9;')
|
|
75
|
+
expect(element(styled, 'ul').style.listStyle).toBe('none')
|
|
76
|
+
expect(element(styled, 'li').style.display).toBe('flex')
|
|
77
|
+
expect(element(styled, 'li p').style.margin).toBe('0px')
|
|
78
|
+
expect(element(styled, 'ul ~ p').hasAttribute('style')).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('leave the HTML as it is for a site with content.css', () => {
|
|
82
|
+
expect(contentHTML(card('compact'), 'classes')).toBe(card('compact'))
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('content styles in the editor', () => {
|
|
87
|
+
const content = `<p>Text</p>${card('compact')}`
|
|
88
|
+
+ '<figure data-align="right"><img src="/a.png" style="float: right;"><figcaption>Caption</figcaption></figure>'
|
|
89
|
+
+ '<div data-youtube-video=""><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe></div>'
|
|
90
|
+
+ '<video src="/a.mp4" controls=""></video>'
|
|
91
|
+
+ '<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Done</p></li></ul>'
|
|
92
|
+
|
|
93
|
+
it('are ignored when HTML with them loads again: it saves the same content', () => {
|
|
94
|
+
const clean = editorWith(content).getHTML()
|
|
95
|
+
editor!.destroy()
|
|
96
|
+
|
|
97
|
+
const styled = inlineContentStyles(clean)
|
|
98
|
+
const reloaded = editorWith(styled).getHTML()
|
|
99
|
+
|
|
100
|
+
expect(reloaded).toBe(clean)
|
|
101
|
+
expect(inlineContentStyles(reloaded)).toBe(styled)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keep a floated image as a figure with its float', () => {
|
|
105
|
+
const html = editorWith(content).getHTML()
|
|
106
|
+
|
|
107
|
+
expect(html).toContain('<figure data-align="right"><img src="/a.png" style="float: right;">')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('keep a responsive YouTube video without a size when it loads again', () => {
|
|
111
|
+
const html = editorWith(content).getHTML()
|
|
112
|
+
|
|
113
|
+
expect(element(html, 'div[data-youtube-video] iframe').hasAttribute('width')).toBe(false)
|
|
114
|
+
expect(editor!.state.doc.child(3).attrs).toMatchObject({ width: null, height: null })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The look of the content the editor saves (link cards, captions, videos, embeds, task lists), described once
|
|
3
|
+
* for the `contentStyles` prop: `inline` writes it into `style` attributes (inlineContentStyles), `classes`
|
|
4
|
+
* leaves it to `tiptapify/content.css` (contentCss) around content in a `.tiptapify-content` element.
|
|
5
|
+
* Colors are left to the site: muted text and borders use opacity, so they suit a light and a dark theme alike.
|
|
6
|
+
* The editor parses its elements by attributes and classes only, so these styles are ignored on load.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// No dependencies: the build imports this file to write dist/content.css
|
|
10
|
+
|
|
11
|
+
export type TiptapifyContentStyles = 'inline' | 'classes'
|
|
12
|
+
|
|
13
|
+
export type ContentRule = { selector: string, style: Record<string, string> }
|
|
14
|
+
|
|
15
|
+
export const CONTENT_CLASS = 'tiptapify-content'
|
|
16
|
+
|
|
17
|
+
const muted = '0.7'
|
|
18
|
+
const border = '1px solid rgba(128, 128, 128, 0.35)'
|
|
19
|
+
const responsive = { width: '100%', height: 'auto', 'aspect-ratio': '16 / 9' }
|
|
20
|
+
|
|
21
|
+
// In order: a later rule wins over an earlier one for the same property
|
|
22
|
+
export const contentRules: ContentRule[] = [
|
|
23
|
+
// Link cards: compact (the image beside the texts, under them when narrow), large (image on top), text
|
|
24
|
+
{
|
|
25
|
+
selector: '.tiptapify-link-card',
|
|
26
|
+
style: {
|
|
27
|
+
display: 'flex',
|
|
28
|
+
'flex-wrap': 'wrap',
|
|
29
|
+
overflow: 'hidden',
|
|
30
|
+
margin: '1rem 0',
|
|
31
|
+
border,
|
|
32
|
+
'border-radius': '10px',
|
|
33
|
+
color: 'inherit',
|
|
34
|
+
'text-decoration': 'none',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
selector: '.tiptapify-link-card__image',
|
|
39
|
+
// min-width 0: an image in a row would not shrink below its own width (an OpenGraph image is 1200px)
|
|
40
|
+
style: { flex: '1 1 180px', 'min-width': '0', 'max-width': '100%', 'min-height': '100px', margin: '0', 'object-fit': 'cover' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
selector: '.tiptapify-link-card__body',
|
|
44
|
+
style: { display: 'flex', flex: '999 1 260px', 'flex-direction': 'column', gap: '4px', 'min-width': '0', padding: '12px 16px' },
|
|
45
|
+
},
|
|
46
|
+
{ selector: '.tiptapify-link-card__site', style: { 'font-size': '0.8125em', opacity: muted } },
|
|
47
|
+
{ selector: '.tiptapify-link-card__title', style: { 'font-weight': '600', 'line-height': '1.35' } },
|
|
48
|
+
{
|
|
49
|
+
selector: '.tiptapify-link-card__description',
|
|
50
|
+
style: {
|
|
51
|
+
display: '-webkit-box',
|
|
52
|
+
overflow: 'hidden',
|
|
53
|
+
'font-size': '0.875em',
|
|
54
|
+
'line-height': '1.45',
|
|
55
|
+
opacity: muted,
|
|
56
|
+
'-webkit-box-orient': 'vertical',
|
|
57
|
+
'-webkit-line-clamp': '3',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
selector: '.tiptapify-link-card[data-link-card="large"]',
|
|
62
|
+
style: { 'flex-direction': 'column', 'flex-wrap': 'nowrap', 'max-width': '520px' },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
selector: '.tiptapify-link-card[data-link-card="large"] .tiptapify-link-card__image',
|
|
66
|
+
style: { flex: 'none', width: '100%', height: 'auto', 'min-height': '0', 'aspect-ratio': '1.91 / 1' },
|
|
67
|
+
},
|
|
68
|
+
{ selector: '.tiptapify-link-card[data-link-card="large"] .tiptapify-link-card__body', style: { flex: 'none' } },
|
|
69
|
+
|
|
70
|
+
// Images with their caption under them: centered, or floated left or right with the text around them
|
|
71
|
+
{
|
|
72
|
+
selector: 'figure',
|
|
73
|
+
style: {
|
|
74
|
+
display: 'flex',
|
|
75
|
+
'flex-direction': 'column',
|
|
76
|
+
gap: '4px',
|
|
77
|
+
width: 'fit-content',
|
|
78
|
+
'max-width': '100%',
|
|
79
|
+
margin: '1rem auto',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{ selector: 'figure:not([data-align])', style: { 'align-items': 'center' } },
|
|
83
|
+
{ selector: 'figure img', style: { display: 'block', 'max-width': '100%', height: 'auto', margin: '0' } },
|
|
84
|
+
{ selector: 'figure figcaption', style: { 'font-size': '0.875em', opacity: muted, 'text-align': 'center' } },
|
|
85
|
+
{ selector: 'figure[data-align] figcaption', style: { 'text-align': 'start' } },
|
|
86
|
+
{
|
|
87
|
+
selector: 'figure[data-align="left"]',
|
|
88
|
+
style: { float: 'left', 'max-width': '50%', margin: '0.25rem 1.25rem 1rem 0' },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
selector: 'figure[data-align="right"]',
|
|
92
|
+
style: { float: 'right', 'max-width': '50%', margin: '0.25rem 0 1rem 1.25rem' },
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Videos and embeds: never wider than the text; without a size they fill its width at 16:9
|
|
96
|
+
{ selector: 'video', style: { display: 'block', 'max-width': '100%' } },
|
|
97
|
+
{ selector: 'video:not([width])', style: responsive },
|
|
98
|
+
{ selector: 'div[data-youtube-video] iframe', style: { display: 'block', 'max-width': '100%', border: '0' } },
|
|
99
|
+
{ selector: 'div[data-youtube-video] iframe:not([width])', style: responsive },
|
|
100
|
+
{ selector: '.iframe-wrapper iframe', style: { display: 'block', 'max-width': '100%' } },
|
|
101
|
+
{ selector: '.iframe-wrapper iframe[src^="https://player.vimeo.com/"]:not([width])', style: responsive },
|
|
102
|
+
|
|
103
|
+
// Task lists: a checkbox beside each item instead of a bullet
|
|
104
|
+
{ selector: 'ul[data-type="taskList"]', style: { 'list-style': 'none', 'margin-left': '0', padding: '0' } },
|
|
105
|
+
{ selector: 'ul[data-type="taskList"] ul[data-type="taskList"]', style: { margin: '0' } },
|
|
106
|
+
{ selector: 'ul[data-type="taskList"] > li', style: { display: 'flex', 'align-items': 'flex-start' } },
|
|
107
|
+
{ selector: 'ul[data-type="taskList"] > li > label', style: { flex: '0 0 auto', 'margin-right': '0.5rem' } },
|
|
108
|
+
{ selector: 'ul[data-type="taskList"] > li > div', style: { flex: '1 1 auto' } },
|
|
109
|
+
// The text of a task sits on the line of its checkbox: no default margin of the paragraph above it
|
|
110
|
+
{ selector: 'ul[data-type="taskList"] > li > div > p', style: { margin: '0' } },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
function declarations(style: Record<string, string>): string {
|
|
114
|
+
return Object.entries(style).map(([property, value]) => `${property}: ${value};`).join(' ')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The rules as a stylesheet for the content inside `scope`. For a site the scope is wrapped in `:where()`, so
|
|
119
|
+
* any rule of the site wins over these without `!important`; the editor keeps the scope's own weight.
|
|
120
|
+
*/
|
|
121
|
+
export function contentCss(scope = `.${CONTENT_CLASS}`, { weightless = true }: { weightless?: boolean } = {}): string {
|
|
122
|
+
const prefix = weightless ? `:where(${scope})` : scope
|
|
123
|
+
|
|
124
|
+
return contentRules.map(({ selector, style }) => `${prefix} ${selector} { ${declarations(style)} }`).join('\n') + '\n'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseStyle(style: string): Map<string, string> {
|
|
128
|
+
const properties = new Map<string, string>()
|
|
129
|
+
for (const declaration of style.split(';')) {
|
|
130
|
+
const colon = declaration.indexOf(':')
|
|
131
|
+
if (colon > 0) {
|
|
132
|
+
properties.set(declaration.slice(0, colon).trim().toLowerCase(), declaration.slice(colon + 1).trim())
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return properties
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The content HTML with the rules written into its `style` attributes. Other properties an element has (the
|
|
141
|
+
* float of an image) are kept and the rules' own are overwritten, so applying it twice changes nothing.
|
|
142
|
+
* Without a DOM (server rendering) the HTML is returned as it is.
|
|
143
|
+
*/
|
|
144
|
+
export function inlineContentStyles(html: string): string {
|
|
145
|
+
if (typeof document === 'undefined' || !html) {
|
|
146
|
+
return html
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The content of a template is inert: no script runs and no image loads while the HTML is restyled
|
|
150
|
+
const template = document.createElement('template')
|
|
151
|
+
template.innerHTML = html
|
|
152
|
+
const styles = new Map<Element, Map<string, string>>()
|
|
153
|
+
|
|
154
|
+
for (const { selector, style } of contentRules) {
|
|
155
|
+
for (const element of template.content.querySelectorAll(selector)) {
|
|
156
|
+
const properties = styles.get(element) ?? parseStyle(element.getAttribute('style') ?? '')
|
|
157
|
+
for (const [property, value] of Object.entries(style)) {
|
|
158
|
+
properties.delete(property)
|
|
159
|
+
properties.set(property, value)
|
|
160
|
+
}
|
|
161
|
+
styles.set(element, properties)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const [element, properties] of styles) {
|
|
166
|
+
element.setAttribute('style', [...properties].map(([property, value]) => `${property}: ${value};`).join(' '))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return template.innerHTML
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The HTML the editor gives out: with the styles written in, or as it is for a site with content.css */
|
|
173
|
+
export function contentHTML(html: string, mode: TiptapifyContentStyles): string {
|
|
174
|
+
return mode === 'inline' ? inlineContentStyles(html) : html
|
|
175
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ratioOf, resizeSide, toSize } from '@tiptapify/utils/mediaSize'
|
|
3
|
+
|
|
4
|
+
describe('media size', () => {
|
|
5
|
+
it('reads a size as a whole positive number', () => {
|
|
6
|
+
expect(toSize('640')).toBe(640)
|
|
7
|
+
expect(toSize(359.6)).toBe(360)
|
|
8
|
+
expect(toSize('')).toBeNull()
|
|
9
|
+
expect(toSize(-5)).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('keeps the other side in the ratio when one side changes', () => {
|
|
13
|
+
expect(resizeSide({ width: 640, height: 360 }, 'width', 1280, 16 / 9)).toEqual({ width: 1280, height: 720 })
|
|
14
|
+
expect(resizeSide({ width: 640, height: 360 }, 'height', 450, 16 / 9)).toEqual({ width: 800, height: 450 })
|
|
15
|
+
expect(resizeSide({ width: 640, height: 360 }, 'width', 800, null)).toEqual({ width: 800, height: 360 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('takes the ratio of the size, else of the fallback', () => {
|
|
19
|
+
expect(ratioOf({ width: 400, height: 300 }, 16 / 9)).toBe(4 / 3)
|
|
20
|
+
expect(ratioOf({ width: 400, height: null }, 16 / 9)).toBe(16 / 9)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MediaSize = { width: number | null, height: number | null }
|
|
2
|
+
|
|
3
|
+
// A positive whole number from a number field or an attribute, else null
|
|
4
|
+
export function toSize(value: unknown): number | null {
|
|
5
|
+
const number = typeof value === 'number' ? value : parseFloat(String(value ?? ''))
|
|
6
|
+
|
|
7
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The size after one side changed: with a ratio (width / height) the other side follows it.
|
|
12
|
+
*/
|
|
13
|
+
export function resizeSide(size: MediaSize, side: keyof MediaSize, value: unknown, ratio: number | null): MediaSize {
|
|
14
|
+
const changed = toSize(value)
|
|
15
|
+
if (!ratio || !changed) {
|
|
16
|
+
return { ...size, [side]: changed }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return side === 'width'
|
|
20
|
+
? { width: changed, height: toSize(changed / ratio) }
|
|
21
|
+
: { width: toSize(changed * ratio), height: changed }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The ratio (width / height) of a size with both sides, else the fallback
|
|
25
|
+
export function ratioOf(size: MediaSize, fallback: number): number {
|
|
26
|
+
return size.width && size.height ? size.width / size.height : fallback
|
|
27
|
+
}
|
|
@@ -67,14 +67,43 @@ describe('isValidHref', () => {
|
|
|
67
67
|
expect(isValidHref('https://.com')).toBe(false)
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
-
it('
|
|
71
|
-
|
|
70
|
+
it('accepts any protocol with a host: ftp, sftp, file shares', () => {
|
|
71
|
+
for (const href of ['ftp://files.example.com/a.zip', 'sftp://user@host/dir', 'smb://server/share', 'ftp://192.168.0.10/']) {
|
|
72
|
+
expect(isValidHref(href), href).toBe(true)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('accepts an internal host without a dot: intranet and localhost', () => {
|
|
77
|
+
for (const href of ['http://intranet/wiki/page', 'http://localhost:8080/admin', 'https://jira/browse/LIGA-1']) {
|
|
78
|
+
expect(isValidHref(href), href).toBe(true)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('accepts other protocols without slashes: sms, skype, magnet', () => {
|
|
83
|
+
for (const href of ['sms:+380441234567', 'skype:user.name?call', 'magnet:?xt=urn:btih:abc']) {
|
|
84
|
+
expect(isValidHref(href), href).toBe(true)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects a host and port typed without a protocol: it is not a protocol', () => {
|
|
89
|
+
expect(isValidHref('localhost:8080/admin')).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('rejects a protocol with slashes but no host', () => {
|
|
93
|
+
expect(isValidHref('ftp://')).toBe(false)
|
|
94
|
+
expect(isValidHref('ftp:///path')).toBe(false)
|
|
72
95
|
})
|
|
73
96
|
|
|
74
97
|
it('rejects javascript pseudo-protocols', () => {
|
|
75
98
|
expect(isValidHref('javascript:alert(1)')).toBe(false)
|
|
76
99
|
})
|
|
77
100
|
|
|
101
|
+
it('rejects the protocols that run code or embed content', () => {
|
|
102
|
+
for (const href of ['JavaScript:alert(1)', 'vbscript:msgbox(1)', 'data:text/html;base64,PHNjcmlwdD4=']) {
|
|
103
|
+
expect(isValidHref(href), href).toBe(false)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
78
107
|
it('rejects URLs containing spaces', () => {
|
|
79
108
|
expect(isValidHref('https://exa mple.com')).toBe(false)
|
|
80
109
|
})
|
|
@@ -90,4 +119,20 @@ describe('isValidHref', () => {
|
|
|
90
119
|
it('rejects plain text', () => {
|
|
91
120
|
expect(isValidHref('not a url')).toBe(false)
|
|
92
121
|
})
|
|
122
|
+
|
|
123
|
+
it('accepts an anchor and links relative to the site or the page', () => {
|
|
124
|
+
for (const href of ['#section-2', '/news/politics?page=2', './about', '../index.html', '?tab=comments']) {
|
|
125
|
+
expect(isValidHref(href), href).toBe(true)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rejects relative links with spaces and a bare anchor or slash', () => {
|
|
130
|
+
for (const href of ['#', '/', '/news/a b', '# heading']) {
|
|
131
|
+
expect(isValidHref(href), href).toBe(false)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('rejects a protocol-relative link', () => {
|
|
136
|
+
expect(isValidHref('//evil.example/x')).toBe(false)
|
|
137
|
+
})
|
|
93
138
|
})
|
|
@@ -5,14 +5,25 @@ const regexTel = 'tel:\\+?[0-9]+'
|
|
|
5
5
|
|
|
6
6
|
const hrefRegex = new RegExp(`^(${regexMailto}|${regexTel})$`, 'i')
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// An anchor (#id) or a link relative to the site (/path) or the page (./, ../, ?query); not a
|
|
9
|
+
// protocol-relative //host, which points to another site
|
|
10
|
+
const relativeHrefRegex = /^(#|\/(?!\/)|\.{1,2}\/|\?)\S+$/
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const schemeRegex = /^([a-z][a-z0-9+.-]*):(.*)$/i
|
|
13
|
+
|
|
14
|
+
// Schemes that run code or embed content instead of pointing to it
|
|
15
|
+
const blockedSchemes = new Set(['javascript', 'vbscript', 'data'])
|
|
16
|
+
|
|
17
|
+
// Schemes of addresses on a host: without `//` (`http:example.com`) they are a typo, not another kind of link
|
|
18
|
+
const hostSchemes = new Set(['http', 'https', 'ftp', 'ftps', 'sftp'])
|
|
19
|
+
|
|
20
|
+
// Host labels without empty ones (`example..com`, `.com`); a single label is an internal host (intranet, localhost)
|
|
21
|
+
const hostnameRegex = /^[^.]+(\.[^.]+)*$/
|
|
15
22
|
|
|
23
|
+
// `localhost:8080/admin` typed without a protocol: a host and a port, not a scheme `localhost:`
|
|
24
|
+
const portRegex = /^\d+([/?#]|$)/
|
|
25
|
+
|
|
26
|
+
function isValidHostUrl(href: string): boolean {
|
|
16
27
|
try {
|
|
17
28
|
return hostnameRegex.test(new URL(href).hostname)
|
|
18
29
|
} catch {
|
|
@@ -20,6 +31,35 @@ function isValidHttpUrl(href: string): boolean {
|
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A link address: any protocol with a host (`https://`, `ftp://`, `smb://`, internal hosts without a dot
|
|
36
|
+
* included), other protocols (`mailto:`, `tel:`, `sms:`, `magnet:`…), anchors and relative links. Protocols
|
|
37
|
+
* that run code (`javascript:`, `vbscript:`, `data:`) are rejected.
|
|
38
|
+
*/
|
|
23
39
|
export function isValidHref(href: string): boolean {
|
|
24
|
-
|
|
40
|
+
const value = href || ''
|
|
41
|
+
if (relativeHrefRegex.test(value)) {
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const match = schemeRegex.exec(value)
|
|
46
|
+
if (!match || /\s/.test(value)) {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scheme = match[1].toLowerCase()
|
|
51
|
+
const rest = match[2]
|
|
52
|
+
if (blockedSchemes.has(scheme)) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (rest.startsWith('//')) {
|
|
57
|
+
return !rest.startsWith('///') && isValidHostUrl(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (scheme === 'mailto' || scheme === 'tel') {
|
|
61
|
+
return hrefRegex.test(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return !hostSchemes.has(scheme) && rest !== '' && !portRegex.test(rest)
|
|
25
65
|
}
|