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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. 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: 'tiptapify_link',
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
- ...this.parent?.(),
18
- showLink: () => ({ editor }: CommandProps) => {
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: editor.getAttributes('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
+ }