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
|
@@ -1,24 +1,129 @@
|
|
|
1
1
|
import { CommandProps } from '@tiptap/core'
|
|
2
2
|
import Link from '@tiptap/extension-link'
|
|
3
|
+
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
4
|
+
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
|
|
5
|
+
import { getLinkContext, LINK_MARK } from '@tiptapify/extensions/components/media/link/linkDialog'
|
|
6
|
+
import { LinkCardDraft, selectedLinkCard } from '@tiptapify/extensions/components/media/linkCard'
|
|
3
7
|
|
|
4
8
|
declare module '@tiptap/core' {
|
|
5
9
|
interface Commands<ReturnType> {
|
|
6
10
|
tiptapify_link: {
|
|
7
|
-
showLink: () => ReturnType
|
|
11
|
+
showLink: (card?: LinkCardDraft) => ReturnType
|
|
8
12
|
}
|
|
9
13
|
}
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
const LEGACY_LINK = 'tiptapify_link'
|
|
17
|
+
|
|
18
|
+
function hasLegacyLinks(doc: ProseMirrorNode): boolean {
|
|
19
|
+
let found = false
|
|
20
|
+
doc.descendants((node) => {
|
|
21
|
+
found ||= node.marks.some(mark => mark.type.name === LEGACY_LINK)
|
|
22
|
+
|
|
23
|
+
return !found
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return found
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `tiptapify_link` marks of a document as `link` marks: a text that had both keeps its `link`, one that
|
|
31
|
+
* had only the legacy mark gets a `link` with its attributes. Null when there is nothing to change.
|
|
32
|
+
*/
|
|
33
|
+
export function migrateLegacyLinks(state: EditorState): Transaction | null {
|
|
34
|
+
const legacy = state.schema.marks[LEGACY_LINK]
|
|
35
|
+
const link = state.schema.marks[LINK_MARK]
|
|
36
|
+
if (!legacy || !link || !hasLegacyLinks(state.doc)) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tr = state.tr
|
|
41
|
+
state.doc.descendants((node, pos) => {
|
|
42
|
+
const old = node.marks.find(mark => mark.type === legacy)
|
|
43
|
+
if (!old) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const end = pos + node.nodeSize
|
|
48
|
+
tr.removeMark(pos, end, legacy)
|
|
49
|
+
if (!link.isInSet(node.marks)) {
|
|
50
|
+
const attrs = Object.fromEntries(Object.keys(link.spec.attrs ?? {}).map(name => [name, old.attrs[name] ?? null]))
|
|
51
|
+
tr.addMark(pos, end, link.create(attrs))
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return tr.setMeta('addToHistory', false)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type JSONNode = { marks?: { type: string, attrs?: Record<string, unknown> }[], content?: JSONNode[], [key: string]: unknown }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The same for the JSON content an editor starts with, before its document is made.
|
|
62
|
+
*/
|
|
63
|
+
export function migrateLegacyLinkJSON(node: JSONNode): JSONNode {
|
|
64
|
+
const marks = node.marks?.some(mark => mark.type === LEGACY_LINK)
|
|
65
|
+
? node.marks
|
|
66
|
+
.filter(mark => mark.type !== LEGACY_LINK || !node.marks!.some(other => other.type === LINK_MARK))
|
|
67
|
+
.map(mark => (mark.type === LEGACY_LINK ? { ...mark, type: LINK_MARK } : mark))
|
|
68
|
+
: node.marks
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...node,
|
|
72
|
+
...(marks ? { marks } : {}),
|
|
73
|
+
...(node.content ? { content: node.content.map(migrateLegacyLinkJSON) } : {}),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The link dialog command. Links themselves are the `link` mark; the legacy `tiptapify_link` mark stays in the
|
|
79
|
+
* schema only so saved JSON that still has it loads, and its marks turn into `link` as they load.
|
|
80
|
+
*/
|
|
12
81
|
export const TiptapifyLink = Link.extend({
|
|
13
|
-
name:
|
|
82
|
+
name: LEGACY_LINK,
|
|
83
|
+
|
|
84
|
+
// `link` reads the links of HTML
|
|
85
|
+
parseHTML() {
|
|
86
|
+
return []
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// No paste rules or commands of its own (`setLink`/`unsetLink` act on `link`): a second link mark would nest links
|
|
90
|
+
addPasteRules() {
|
|
91
|
+
return []
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
addProseMirrorPlugins() {
|
|
95
|
+
return [
|
|
96
|
+
new Plugin({
|
|
97
|
+
key: new PluginKey('tiptapifyLegacyLinks'),
|
|
98
|
+
appendTransaction: (transactions, _oldState, newState) => (
|
|
99
|
+
transactions.some(tr => tr.docChanged) ? migrateLegacyLinks(newState) : null
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
onBeforeCreate() {
|
|
106
|
+
const { content } = this.editor.options
|
|
107
|
+
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
108
|
+
this.editor.options.content = migrateLegacyLinkJSON(content as JSONNode) as never
|
|
109
|
+
}
|
|
110
|
+
},
|
|
14
111
|
|
|
15
112
|
addCommands() {
|
|
16
113
|
return {
|
|
17
|
-
|
|
18
|
-
|
|
114
|
+
showLink: card => ({ editor }: CommandProps) => {
|
|
115
|
+
const { active, attrs, text } = getLinkContext(editor)
|
|
116
|
+
|
|
117
|
+
// `link`: the attributes of the link being edited (empty when inserting a new one);
|
|
118
|
+
// `text`: its text, or the text of the selection; `card`: the selected card to edit;
|
|
119
|
+
// `draft`: an address to make a card of (the dialog opens showing it as a card)
|
|
19
120
|
const event = new CustomEvent('tiptapify-show-link', {
|
|
20
121
|
detail: {
|
|
21
|
-
link:
|
|
122
|
+
link: attrs,
|
|
123
|
+
active,
|
|
124
|
+
text,
|
|
125
|
+
card: selectedLinkCard(editor),
|
|
126
|
+
draft: card ?? null,
|
|
22
127
|
editorId: editor.instanceId
|
|
23
128
|
}
|
|
24
129
|
})
|
|
@@ -29,4 +134,4 @@ export const TiptapifyLink = Link.extend({
|
|
|
29
134
|
},
|
|
30
135
|
}
|
|
31
136
|
},
|
|
32
|
-
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
4
|
+
|
|
5
|
+
// jsdom has no ClipboardEvent, which a paste of the editor creates
|
|
6
|
+
globalThis.ClipboardEvent ??= class extends Event {
|
|
7
|
+
clipboardData = null
|
|
8
|
+
} as never
|
|
9
|
+
|
|
10
|
+
let editor: Editor | null = null
|
|
11
|
+
|
|
12
|
+
function createEditor(content: string | Record<string, unknown> = '<p></p>') {
|
|
13
|
+
editor = new Editor({ element: document.createElement('div'), content, extensions: editorExtensions('', false, []) })
|
|
14
|
+
|
|
15
|
+
return editor
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function markNames(): string[] {
|
|
19
|
+
const names: string[] = []
|
|
20
|
+
editor!.state.doc.descendants((node) => {
|
|
21
|
+
node.marks.forEach(mark => names.push(mark.type.name))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return names
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const anchors = () => (editor!.getHTML().match(/<a /g) ?? []).length
|
|
28
|
+
|
|
29
|
+
describe('one link mark', () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
editor?.destroy()
|
|
32
|
+
editor = null
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('makes one link of a pasted URL, not a link inside a link', () => {
|
|
36
|
+
createEditor()
|
|
37
|
+
|
|
38
|
+
editor!.view.pasteText('https://www.w3schools.com/')
|
|
39
|
+
|
|
40
|
+
expect(anchors()).toBe(1)
|
|
41
|
+
expect(markNames()).toEqual(['link'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('reads a link of the saved HTML as one link', () => {
|
|
45
|
+
createEditor('<p>Read <a href="https://example.com">this</a></p>')
|
|
46
|
+
|
|
47
|
+
expect(anchors()).toBe(1)
|
|
48
|
+
expect(markNames()).toEqual(['link'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('sets and removes links with the link mark', () => {
|
|
52
|
+
createEditor('<p>Read this</p>')
|
|
53
|
+
|
|
54
|
+
editor!.chain().setTextSelection({ from: 6, to: 10 }).setLink({ href: 'https://example.com' }).run()
|
|
55
|
+
expect(markNames()).toEqual(['link'])
|
|
56
|
+
|
|
57
|
+
editor!.chain().setTextSelection({ from: 6, to: 10 }).unsetLink().run()
|
|
58
|
+
expect(markNames()).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('loads the saved JSON of earlier versions (tiptapify_link marks) as one link', () => {
|
|
62
|
+
const saved = {
|
|
63
|
+
type: 'doc',
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'paragraph',
|
|
66
|
+
content: [{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: 'site',
|
|
69
|
+
marks: [
|
|
70
|
+
{ type: 'link', attrs: { href: 'https://example.com' } },
|
|
71
|
+
{ type: 'tiptapify_link', attrs: { href: 'https://example.com' } },
|
|
72
|
+
],
|
|
73
|
+
}, {
|
|
74
|
+
type: 'text',
|
|
75
|
+
text: ' and old',
|
|
76
|
+
marks: [{ type: 'tiptapify_link', attrs: { href: 'https://old.example.com', target: '_blank' } }],
|
|
77
|
+
}],
|
|
78
|
+
}],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
createEditor(saved)
|
|
82
|
+
expect(editor!.getHTML()).toBe('<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">site</a><a target="_blank" rel="noopener noreferrer nofollow" href="https://old.example.com"> and old</a></p>')
|
|
83
|
+
expect(markNames().every(name => name === 'link')).toBe(true)
|
|
84
|
+
|
|
85
|
+
editor!.commands.setContent(saved)
|
|
86
|
+
expect(markNames().every(name => name === 'link')).toBe(true)
|
|
87
|
+
expect(anchors()).toBe(2)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/vue-3'
|
|
3
|
+
import { Document } from '@tiptap/extension-document'
|
|
4
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
5
|
+
import { Text } from '@tiptap/extension-text'
|
|
6
|
+
import { Link } from '@tiptap/extension-link'
|
|
7
|
+
import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
|
|
8
|
+
import { LinkCard, LinkCardAttrs } from '@tiptapify/extensions/components/media/linkCard'
|
|
9
|
+
import {
|
|
10
|
+
applyLink,
|
|
11
|
+
formRel,
|
|
12
|
+
getLinkContext,
|
|
13
|
+
hasAdvancedValues,
|
|
14
|
+
LinkForm,
|
|
15
|
+
linkAttrs,
|
|
16
|
+
linkToCard,
|
|
17
|
+
missingProtocol,
|
|
18
|
+
parseRel,
|
|
19
|
+
removeLink,
|
|
20
|
+
serializeRel,
|
|
21
|
+
withProtocol,
|
|
22
|
+
withNewTabRel,
|
|
23
|
+
} from '@tiptapify/extensions/components/media/link/linkDialog'
|
|
24
|
+
|
|
25
|
+
describe('missingProtocol', () => {
|
|
26
|
+
it.each([
|
|
27
|
+
'example.com',
|
|
28
|
+
' www.example.com/page?x=1#top ',
|
|
29
|
+
'internal.com:21',
|
|
30
|
+
'sub.example.co.uk:8080/path',
|
|
31
|
+
'localhost:8080/admin',
|
|
32
|
+
'intranet:3000',
|
|
33
|
+
])('%s needs a protocol', (href) => {
|
|
34
|
+
expect(missingProtocol(href)).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
'http://example.com',
|
|
39
|
+
'HTTPS://example.com',
|
|
40
|
+
'ftp://files.example.com',
|
|
41
|
+
'smb://server/share',
|
|
42
|
+
'mailto:user@example.com',
|
|
43
|
+
'tel:+380441234567',
|
|
44
|
+
'#anchor',
|
|
45
|
+
'/relative/path',
|
|
46
|
+
'./page',
|
|
47
|
+
'../page',
|
|
48
|
+
'?query=1',
|
|
49
|
+
'user@example.com',
|
|
50
|
+
'word',
|
|
51
|
+
'',
|
|
52
|
+
])('%s does not', (href) => {
|
|
53
|
+
expect(missingProtocol(href)).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('withProtocol', () => {
|
|
58
|
+
it('puts the chosen protocol before the address', () => {
|
|
59
|
+
expect(withProtocol(' internal.com:21 ', 'ftp')).toBe('ftp://internal.com:21')
|
|
60
|
+
expect(withProtocol('intranet/wiki', 'http')).toBe('http://intranet/wiki')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('rel helpers', () => {
|
|
65
|
+
it('parses an attribute string and pasted lists', () => {
|
|
66
|
+
expect(parseRel(' NoFollow ugc,sponsored ')).toEqual(['nofollow', 'ugc', 'sponsored'])
|
|
67
|
+
expect(parseRel(['nofollow', 'ugc, nofollow', ' Me '])).toEqual(['nofollow', 'ugc', 'me'])
|
|
68
|
+
expect(parseRel(null)).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('serializes to an attribute or null', () => {
|
|
72
|
+
expect(serializeRel(['nofollow', 'NOFOLLOW', 'ugc'])).toBe('nofollow ugc')
|
|
73
|
+
expect(serializeRel([])).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('adds noopener noreferrer for a new tab only', () => {
|
|
77
|
+
expect(withNewTabRel(['nofollow'], true)).toEqual(['nofollow', 'noopener', 'noreferrer'])
|
|
78
|
+
expect(withNewTabRel(['noopener'], true)).toEqual(['noopener', 'noreferrer'])
|
|
79
|
+
expect(withNewTabRel(['nofollow'], false)).toEqual(['nofollow'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('hides the values implied by a new tab in the form', () => {
|
|
83
|
+
expect(formRel('noopener noreferrer nofollow', true)).toEqual(['nofollow'])
|
|
84
|
+
expect(formRel('noopener nofollow', false)).toEqual(['noopener', 'nofollow'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('detects settings of the advanced section', () => {
|
|
88
|
+
expect(hasAdvancedValues(['noopener', 'noreferrer'], '')).toBe(false)
|
|
89
|
+
expect(hasAdvancedValues(['nofollow'], '')).toBe(true)
|
|
90
|
+
expect(hasAdvancedValues([], ' btn ')).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('builds mark attributes', () => {
|
|
94
|
+
const form: LinkForm = { href: ' ftp://example.com ', text: '', newTab: false, rel: [], cssClass: ' ' }
|
|
95
|
+
|
|
96
|
+
expect(linkAttrs(form)).toEqual({ href: 'ftp://example.com', target: null, rel: null, class: null })
|
|
97
|
+
expect(linkAttrs({ ...form, newTab: true, rel: ['ugc'], cssClass: 'btn' }))
|
|
98
|
+
.toEqual({ href: 'ftp://example.com', target: '_blank', rel: 'ugc noopener noreferrer', class: 'btn' })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('apply logic', () => {
|
|
103
|
+
let editor: Editor | null = null
|
|
104
|
+
|
|
105
|
+
function createEditor(content: string) {
|
|
106
|
+
editor = new Editor({
|
|
107
|
+
element: document.createElement('div'),
|
|
108
|
+
content,
|
|
109
|
+
// The same pair of link extensions the editor registers
|
|
110
|
+
extensions: [
|
|
111
|
+
Document,
|
|
112
|
+
Paragraph,
|
|
113
|
+
Text,
|
|
114
|
+
Link.configure({ openOnClick: false, enableClickSelection: true, defaultProtocol: 'https' }),
|
|
115
|
+
TiptapifyLink.configure({ openOnClick: false, defaultProtocol: 'https' }),
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return editor
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function form(overrides: Partial<LinkForm> = {}): LinkForm {
|
|
123
|
+
return { href: 'https://example.com', text: '', newTab: false, rel: [], cssClass: '', ...overrides }
|
|
124
|
+
}
|
|
125
|
+
it('never adds a protocol of its own: the address is saved as typed', () => {
|
|
126
|
+
const ed = createEditor('<p>intranet</p>')
|
|
127
|
+
ed.commands.setTextSelection({ from: 1, to: 9 })
|
|
128
|
+
|
|
129
|
+
applyLink(ed, form({ href: ' ftp://internal.com:21/report.zip ', text: 'intranet' }), getLinkContext(ed))
|
|
130
|
+
|
|
131
|
+
expect(ed.getHTML()).toContain('href="ftp://internal.com:21/report.zip"')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
editor?.destroy()
|
|
138
|
+
editor = null
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('inserts the text with the link on an empty selection', () => {
|
|
142
|
+
const ed = createEditor('<p>Hello world</p>')
|
|
143
|
+
ed.commands.setTextSelection(7)
|
|
144
|
+
const context = getLinkContext(ed)
|
|
145
|
+
|
|
146
|
+
expect(context).toEqual({ active: false, attrs: {}, text: '' })
|
|
147
|
+
expect(applyLink(ed, form({ text: 'site' }), context)).toBe(true)
|
|
148
|
+
expect(ed.getHTML()).toBe('<p>Hello <a href="https://example.com">site</a>world</p>')
|
|
149
|
+
|
|
150
|
+
// Typing right after the new link does not extend it
|
|
151
|
+
ed.commands.insertContent(' ')
|
|
152
|
+
expect(ed.getHTML()).toBe('<p>Hello <a href="https://example.com">site</a> world</p>')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('uses the address as the text when both are empty', () => {
|
|
156
|
+
const ed = createEditor('<p>Hello world</p>')
|
|
157
|
+
ed.commands.setTextSelection(7)
|
|
158
|
+
|
|
159
|
+
applyLink(ed, form(), getLinkContext(ed))
|
|
160
|
+
|
|
161
|
+
expect(ed.getHTML()).toBe('<p>Hello <a href="https://example.com">https://example.com</a>world</p>')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('sets only the mark when the selected text is unchanged', () => {
|
|
165
|
+
const ed = createEditor('<p>Hello world</p>')
|
|
166
|
+
ed.commands.setTextSelection({ from: 7, to: 12 })
|
|
167
|
+
const context = getLinkContext(ed)
|
|
168
|
+
|
|
169
|
+
expect(context.text).toBe('world')
|
|
170
|
+
applyLink(ed, form({ text: 'world', newTab: true, rel: ['nofollow'] }), context)
|
|
171
|
+
|
|
172
|
+
expect(ed.getHTML()).toBe(
|
|
173
|
+
'<p>Hello <a target="_blank" rel="nofollow noopener noreferrer" href="https://example.com">world</a></p>',
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('replaces the selection when the text is changed', () => {
|
|
178
|
+
const ed = createEditor('<p>Hello world</p>')
|
|
179
|
+
ed.commands.setTextSelection({ from: 7, to: 12 })
|
|
180
|
+
const context = getLinkContext(ed)
|
|
181
|
+
|
|
182
|
+
applyLink(ed, form({ text: 'there' }), context)
|
|
183
|
+
|
|
184
|
+
expect(ed.getHTML()).toBe('<p>Hello <a href="https://example.com">there</a></p>')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('updates the attributes of the link under the caret, keeping the others', () => {
|
|
188
|
+
const ed = createEditor('<p>Go <a href="https://old.com" target="_blank" rel="noopener noreferrer" class="btn" title="Old">old site</a> now</p>')
|
|
189
|
+
ed.commands.setTextSelection(6)
|
|
190
|
+
const context = getLinkContext(ed)
|
|
191
|
+
|
|
192
|
+
expect(context.active).toBe(true)
|
|
193
|
+
expect(context.text).toBe('old site')
|
|
194
|
+
expect(context.attrs).toMatchObject({ href: 'https://old.com', target: '_blank', class: 'btn' })
|
|
195
|
+
|
|
196
|
+
applyLink(ed, form({ href: 'https://new.com', text: 'old site', newTab: false, rel: ['ugc'] }), context)
|
|
197
|
+
|
|
198
|
+
expect(ed.getHTML()).toBe('<p>Go <a rel="ugc" href="https://new.com" title="Old">old site</a> now</p>')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('replaces the text of the edited link', () => {
|
|
202
|
+
const ed = createEditor('<p>Go <a href="https://old.com" title="Old">old site</a> now</p>')
|
|
203
|
+
ed.commands.setTextSelection(6)
|
|
204
|
+
const context = getLinkContext(ed)
|
|
205
|
+
|
|
206
|
+
applyLink(ed, form({ href: 'https://old.com', text: 'new site' }), context)
|
|
207
|
+
|
|
208
|
+
expect(ed.getHTML()).toBe('<p>Go <a href="https://old.com" title="Old">new site</a> now</p>')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('removes the whole link under the caret', () => {
|
|
212
|
+
const ed = createEditor('<p>Go <a href="https://old.com">old site</a> now</p>')
|
|
213
|
+
ed.commands.setTextSelection(6)
|
|
214
|
+
|
|
215
|
+
expect(removeLink(ed)).toBe(true)
|
|
216
|
+
expect(ed.getHTML()).toBe('<p>Go old site now</p>')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('linkToCard', () => {
|
|
221
|
+
let editor: Editor | null = null
|
|
222
|
+
|
|
223
|
+
const card: LinkCardAttrs = {
|
|
224
|
+
href: 'https://example.com/page',
|
|
225
|
+
title: 'Page',
|
|
226
|
+
description: null,
|
|
227
|
+
image: null,
|
|
228
|
+
siteName: null,
|
|
229
|
+
layout: 'compact',
|
|
230
|
+
newTab: false,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function createEditor(content: string) {
|
|
234
|
+
editor = new Editor({
|
|
235
|
+
element: document.createElement('div'),
|
|
236
|
+
content,
|
|
237
|
+
extensions: [
|
|
238
|
+
Document,
|
|
239
|
+
Paragraph,
|
|
240
|
+
Text,
|
|
241
|
+
Link.configure({ openOnClick: false, enableClickSelection: true, defaultProtocol: 'https' }),
|
|
242
|
+
TiptapifyLink.configure({ openOnClick: false, defaultProtocol: 'https' }),
|
|
243
|
+
LinkCard,
|
|
244
|
+
],
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return editor
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function types(ed: Editor): string[] {
|
|
251
|
+
const names: string[] = []
|
|
252
|
+
ed.state.doc.forEach(node => names.push(node.type.name))
|
|
253
|
+
|
|
254
|
+
return names
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
afterEach(() => {
|
|
258
|
+
editor?.destroy()
|
|
259
|
+
editor = null
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('replaces the paragraph of a link that is its whole text', () => {
|
|
263
|
+
const ed = createEditor('<p>Before</p><p><a href="https://example.com/page">Page</a></p><p>After</p>')
|
|
264
|
+
ed.commands.setTextSelection(11)
|
|
265
|
+
|
|
266
|
+
expect(linkToCard(ed, card, getLinkContext(ed))).toBe(true)
|
|
267
|
+
|
|
268
|
+
expect(types(ed)).toEqual(['paragraph', 'linkCard', 'paragraph'])
|
|
269
|
+
expect(ed.state.doc.child(1).attrs).toEqual(card)
|
|
270
|
+
expect(ed.getHTML()).not.toContain('<p></p>')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('replaces only the link inside a longer text, splitting the paragraph', () => {
|
|
274
|
+
const ed = createEditor('<p>See <a href="https://example.com/page">Page</a> now</p>')
|
|
275
|
+
ed.commands.setTextSelection(7)
|
|
276
|
+
|
|
277
|
+
linkToCard(ed, card, getLinkContext(ed))
|
|
278
|
+
|
|
279
|
+
expect(types(ed)).toEqual(['paragraph', 'linkCard', 'paragraph'])
|
|
280
|
+
expect(ed.state.doc.child(0).textContent).toBe('See ')
|
|
281
|
+
expect(ed.state.doc.child(2).textContent).toBe(' now')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('takes the place of an empty paragraph, or of the selection', () => {
|
|
285
|
+
const ed = createEditor('<p>One</p><p></p>')
|
|
286
|
+
ed.commands.setTextSelection(6)
|
|
287
|
+
|
|
288
|
+
linkToCard(ed, card, getLinkContext(ed))
|
|
289
|
+
expect(types(ed)).toEqual(['paragraph', 'linkCard'])
|
|
290
|
+
|
|
291
|
+
ed.commands.setContent('<p>Hello world</p>')
|
|
292
|
+
ed.commands.setTextSelection({ from: 1, to: 12 })
|
|
293
|
+
linkToCard(ed, card, getLinkContext(ed))
|
|
294
|
+
expect(types(ed)).toEqual(['linkCard'])
|
|
295
|
+
})
|
|
296
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Editor, getMarkRange } from '@tiptap/core'
|
|
2
|
+
import { LinkCardAttrs } from '@tiptapify/extensions/components/media/linkCard'
|
|
3
|
+
|
|
4
|
+
// Legacy `tiptapify_link` marks turn into this one as content loads (`migrateLegacyLinks` in ./index.ts)
|
|
5
|
+
export const LINK_MARK = 'link'
|
|
6
|
+
|
|
7
|
+
// Link types from the HTML spec, offered as suggestions; any other value is accepted too
|
|
8
|
+
export const REL_SUGGESTIONS = [
|
|
9
|
+
'alternate', 'author', 'bookmark', 'external', 'help', 'license', 'me', 'next', 'nofollow', 'noopener',
|
|
10
|
+
'noreferrer', 'opener', 'prev', 'privacy-policy', 'search', 'sponsored', 'tag', 'ugc',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
// Added automatically to a link that opens in a new tab
|
|
14
|
+
export const NEW_TAB_REL = ['noopener', 'noreferrer']
|
|
15
|
+
|
|
16
|
+
export interface LinkContext {
|
|
17
|
+
// A link is under the caret or selected: the dialog edits it
|
|
18
|
+
active: boolean
|
|
19
|
+
attrs: Record<string, unknown>
|
|
20
|
+
// Text of the link being edited, or of the selection
|
|
21
|
+
text: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LinkForm {
|
|
25
|
+
href: string
|
|
26
|
+
text: string
|
|
27
|
+
newTab: boolean
|
|
28
|
+
rel: string[]
|
|
29
|
+
cssClass: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const schemeRegex = /^[a-z][a-z0-9+-]*:/i
|
|
33
|
+
// A host with a dot (optionally a port), then a path, query, fragment or the end
|
|
34
|
+
const hostRegex = /^[^\s/?#@:]+\.[^\s/?#@:.]+(:\d+)?([/?#]|$)/
|
|
35
|
+
// A host with a port and no dot (`localhost:8080`): it looks like a scheme, but the digits are a port
|
|
36
|
+
const hostPortRegex = /^[^\s/?#@:.]+:\d+([/?#]|$)/
|
|
37
|
+
|
|
38
|
+
// Offered when an address has no protocol: the editor never picks one itself
|
|
39
|
+
export const LINK_PROTOCOLS = ['https', 'http', 'ftp']
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An address of a host typed without a protocol (`example.com/page`, `internal.com:21`, `localhost:8080`):
|
|
43
|
+
* a browser can't open it, and only the author knows which protocol it is, so the dialog asks for one.
|
|
44
|
+
* Explicit protocols, anchors, relative paths and queries are complete
|
|
45
|
+
*/
|
|
46
|
+
export function missingProtocol(value: string): boolean {
|
|
47
|
+
const href = (value ?? '').trim()
|
|
48
|
+
|
|
49
|
+
return hostPortRegex.test(href) || (!schemeRegex.test(href) && hostRegex.test(href))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function withProtocol(value: string, protocol: string): string {
|
|
53
|
+
return `${protocol}://${(value ?? '').trim()}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rel values from an attribute string or a list (chips, pasted text): split on spaces and commas,
|
|
58
|
+
* trimmed, lowercased, deduplicated
|
|
59
|
+
*/
|
|
60
|
+
export function parseRel(value: string | string[] | null | undefined): string[] {
|
|
61
|
+
const parts = Array.isArray(value) ? value : [value ?? '']
|
|
62
|
+
const values = parts.flatMap(part => String(part ?? '').split(/[\s,]+/))
|
|
63
|
+
.map(item => item.trim().toLowerCase())
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
|
|
66
|
+
return [...new Set(values)]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function serializeRel(values: string[]): string | null {
|
|
70
|
+
const rel = parseRel(values)
|
|
71
|
+
|
|
72
|
+
return rel.length ? rel.join(' ') : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function withNewTabRel(rel: string[], newTab: boolean): string[] {
|
|
76
|
+
return newTab ? parseRel([...rel, ...NEW_TAB_REL]) : parseRel(rel)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The values the form shows: those added automatically for a new tab are implied by the checkbox
|
|
80
|
+
export function formRel(rel: string | null | undefined, newTab: boolean): string[] {
|
|
81
|
+
const values = parseRel(rel)
|
|
82
|
+
|
|
83
|
+
return newTab ? values.filter(item => !NEW_TAB_REL.includes(item)) : values
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasAdvancedValues(rel: string[], cssClass: string): boolean {
|
|
87
|
+
return rel.some(item => !NEW_TAB_REL.includes(item)) || cssClass.trim() !== ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function linkAttrs(form: LinkForm) {
|
|
91
|
+
return {
|
|
92
|
+
href: form.href.trim(),
|
|
93
|
+
target: form.newTab ? '_blank' : null,
|
|
94
|
+
rel: serializeRel(withNewTabRel(form.rel, form.newTab)),
|
|
95
|
+
class: form.cssClass.trim() || null,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getLinkContext(editor: Editor): LinkContext {
|
|
100
|
+
const { state } = editor
|
|
101
|
+
const { selection, doc } = state
|
|
102
|
+
const markType = state.schema.marks[LINK_MARK]
|
|
103
|
+
const range = markType && editor.isActive(LINK_MARK) ? getMarkRange(selection.$from, markType) : undefined
|
|
104
|
+
|
|
105
|
+
if (range) {
|
|
106
|
+
return { active: true, attrs: editor.getAttributes(LINK_MARK), text: doc.textBetween(range.from, range.to, ' ') }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { active: false, attrs: {}, text: doc.textBetween(selection.from, selection.to, ' ') }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sets the link on the selection or on the whole link being edited. A changed text (or no text
|
|
114
|
+
* to link) replaces the range with the text carrying the link; otherwise only the mark is set,
|
|
115
|
+
* which keeps the attributes the dialog does not know about (e.g. `title`)
|
|
116
|
+
*/
|
|
117
|
+
export function applyLink(editor: Editor, form: LinkForm, context: LinkContext): boolean {
|
|
118
|
+
const attrs = linkAttrs(form)
|
|
119
|
+
const replaceText = form.text !== context.text || (!context.active && editor.state.selection.empty)
|
|
120
|
+
const chain = editor.chain().focus().extendMarkRange(LINK_MARK).setMeta('preventAutolink', true)
|
|
121
|
+
|
|
122
|
+
if (!replaceText) {
|
|
123
|
+
return chain.setMark(LINK_MARK, attrs).run()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = form.text.trim() ? form.text : attrs.href
|
|
127
|
+
|
|
128
|
+
return chain
|
|
129
|
+
.insertContent({ type: 'text', text, marks: [{ type: LINK_MARK, attrs: { ...context.attrs, ...attrs } }] })
|
|
130
|
+
// The mark is inclusive: what is typed right after the new link must not extend it
|
|
131
|
+
.unsetMark(LINK_MARK)
|
|
132
|
+
.run()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function removeLink(editor: Editor): boolean {
|
|
136
|
+
return editor.chain().focus().extendMarkRange(LINK_MARK).unsetMark(LINK_MARK).run()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The link being edited (or the selection, or the caret) replaced with a card of the link. A link that is
|
|
141
|
+
* the whole text of its paragraph takes the paragraph's place, as does a card made in an empty paragraph.
|
|
142
|
+
*/
|
|
143
|
+
export function linkToCard(editor: Editor, attrs: LinkCardAttrs, context: LinkContext): boolean {
|
|
144
|
+
const { state } = editor
|
|
145
|
+
const { $from } = state.selection
|
|
146
|
+
const markType = state.schema.marks[LINK_MARK]
|
|
147
|
+
const range = (context.active && markType ? getMarkRange($from, markType) : undefined)
|
|
148
|
+
?? { from: state.selection.from, to: state.selection.to }
|
|
149
|
+
const wholeParagraph = $from.parent.type.name === 'paragraph' && $from.depth > 0
|
|
150
|
+
&& range.from === $from.start() && range.to === $from.end()
|
|
151
|
+
const target = wholeParagraph ? { from: $from.before(), to: $from.after() } : range
|
|
152
|
+
|
|
153
|
+
return editor.chain().focus().insertContentAt(target, { type: 'linkCard', attrs }).run()
|
|
154
|
+
}
|