tiptapify 0.3.4 → 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 +110820 -65669
  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 +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -3,6 +3,7 @@ import { Editor } from '@tiptap/vue-3'
3
3
  import { Document } from '@tiptap/extension-document'
4
4
  import { Paragraph } from '@tiptap/extension-paragraph'
5
5
  import { Text } from '@tiptap/extension-text'
6
+ import { UndoRedo } from '@tiptap/extensions'
6
7
  import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
7
8
  import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
8
9
 
@@ -16,6 +17,7 @@ function createEditor(content: string) {
16
17
  Document,
17
18
  Paragraph,
18
19
  Text,
20
+ UndoRedo,
19
21
  TiptapifyImage,
20
22
  ImageWithCaption.configure({
21
23
  resize: { enabled: true, alwaysPreserveAspectRatio: true },
@@ -112,11 +114,74 @@ describe('image caption', () => {
112
114
  const nodeView = editor!.view.nodeDOM(0) as HTMLElement
113
115
 
114
116
  expect(nodeView.dataset.align).toBe('right')
115
- expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png" style="float: right;"></figure>')
117
+ expect(editor!.getHTML()).toBe('<figure data-align="right"><img src="https://example.com/a.png" style="float: right;"></figure>')
116
118
 
117
119
  editor!.chain().setNodeSelection(0).updateAttributes('image', { align: null }).run()
118
120
 
119
121
  expect(nodeView.dataset.align).toBeUndefined()
120
122
  expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"></figure>')
121
123
  })
124
+
125
+ it('marks an image with a set width, which the max height does not cap', () => {
126
+ createEditor('<img src="https://example.com/a.png">')
127
+ const nodeView = editor!.view.nodeDOM(0) as HTMLElement
128
+
129
+ expect(nodeView.dataset.sized).toBeUndefined()
130
+
131
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { width: 468, height: 701 }).run()
132
+
133
+ expect(nodeView.dataset.sized).toBe('')
134
+
135
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { width: null, height: null }).run()
136
+
137
+ expect(nodeView.dataset.sized).toBeUndefined()
138
+ })
139
+
140
+ it('shows the size of a resize undone and redone', () => {
141
+ createEditor('<figure><img src="https://example.com/a.png" width="267" height="400"></figure>')
142
+ const img = (editor!.view.nodeDOM(0) as HTMLElement).querySelector('img')!
143
+
144
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { width: 468, height: 701 }).run()
145
+ expect([img.style.width, img.style.height]).toEqual(['468px', '701px'])
146
+
147
+ editor!.commands.undo()
148
+ expect([img.style.width, img.style.height]).toEqual(['267px', '400px'])
149
+
150
+ editor!.commands.redo()
151
+ expect([img.style.width, img.style.height]).toEqual(['468px', '701px'])
152
+ })
153
+
154
+ it('keeps the size being dragged when the editor reads the resized image back', async () => {
155
+ createEditor('<figure><img src="https://example.com/a.png" width="200" height="300"></figure><p>text</p>')
156
+ const img = (editor!.view.nodeDOM(0) as HTMLElement).querySelector('img')!
157
+ Object.defineProperty(img, 'offsetWidth', { configurable: true, get: () => parseFloat(img.style.width) })
158
+ Object.defineProperty(img, 'offsetHeight', { configurable: true, get: () => parseFloat(img.style.height) })
159
+ const handle = editor!.view.dom.querySelector('[data-resize-handle="bottom-right"]')!
160
+
161
+ handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 0, clientY: 0 }))
162
+ document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 0 }))
163
+ // The editor observes the image style the drag changes and updates the node view
164
+ await new Promise(resolve => setTimeout(resolve))
165
+
166
+ expect(img.style.width).toBe('300px')
167
+
168
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 100, clientY: 0 }))
169
+
170
+ expect(editor!.getJSON().content![0].attrs).toMatchObject({ width: 300, height: 450 })
171
+ })
172
+
173
+ it('drops the shown size when the size is removed', () => {
174
+ createEditor('<figure><img src="https://example.com/a.png" width="267" height="400"></figure>')
175
+ const img = (editor!.view.nodeDOM(0) as HTMLElement).querySelector('img')!
176
+
177
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { width: null, height: null }).run()
178
+
179
+ expect([img.style.width, img.style.height]).toEqual(['', ''])
180
+ })
181
+
182
+ it('loads an image with a width as sized', () => {
183
+ createEditor('<figure><img src="https://example.com/a.png" width="267" height="400"></figure>')
184
+
185
+ expect((editor!.view.nodeDOM(0) as HTMLElement).dataset.sized).toBe('')
186
+ })
122
187
  })
@@ -74,7 +74,8 @@ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
74
74
  HTMLAttributes,
75
75
  align ? { style: `float: ${align}` } : {},
76
76
  )
77
- const figure: [string, Record<string, unknown>, ...unknown[]] = ['figure', {}, ['img', imageAttributes]]
77
+ // The figure carries the float too (`data-align`), for the content styles to float it with its caption
78
+ const figure: [string, Record<string, unknown>, ...unknown[]] = ['figure', align ? { 'data-align': align } : {}, ['img', imageAttributes]]
78
79
  if (title) {
79
80
  figure.push(['figcaption', {}, String(title)])
80
81
  }
@@ -158,6 +159,17 @@ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
158
159
  }
159
160
  syncAlign(node.attrs.align)
160
161
 
162
+ // An image with a set width (from the dialog or resizing) is shown at that width: CSS caps by height only
163
+ // the images without one, so resizing can make an image larger than the max height
164
+ const syncSized = (width: unknown) => {
165
+ if (width) {
166
+ nodeView.dom.dataset.sized = ''
167
+ } else {
168
+ delete nodeView.dom.dataset.sized
169
+ }
170
+ }
171
+ syncSized(node.attrs.width)
172
+
161
173
  // The aspect ratio lets CSS cap a tall image by its height without distorting it
162
174
  const img = nodeView.dom.querySelector('img') as HTMLImageElement
163
175
  const setAspectRatio = () => {
@@ -167,6 +179,18 @@ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
167
179
  }
168
180
  img.addEventListener('load', setAspectRatio)
169
181
  setAspectRatio()
182
+
183
+ // The resizable view reads the size from the attributes only once, so later changes (undo, the dialog)
184
+ // are applied here; only a changed size, since the view also updates mid-drag
185
+ const syncSize = (previous: ProseMirrorNode, updated: ProseMirrorNode) => {
186
+ const { width, height } = updated.attrs
187
+ if (width === previous.attrs.width && height === previous.attrs.height) {
188
+ return
189
+ }
190
+
191
+ img.style.width = width ? `${width}px` : ''
192
+ img.style.height = height ? `${height}px` : ''
193
+ }
170
194
  nodeView.dom.classList.add('tiptapify-image')
171
195
  nodeView.dom.append(caption)
172
196
 
@@ -226,6 +250,7 @@ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
226
250
  nodeView.update = (updatedNode: ProseMirrorNode, ...args: unknown[]) => {
227
251
  const updated = updateResizableView(updatedNode, ...args)
228
252
  if (updated) {
253
+ syncSize(node, updatedNode)
229
254
  node = updatedNode
230
255
  const title = updatedNode.attrs.title ?? ''
231
256
  if (caption.value !== title && document.activeElement !== caption) {
@@ -233,6 +258,7 @@ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
233
258
  }
234
259
  caption.disabled = !editor.isEditable
235
260
  syncAlign(updatedNode.attrs.align)
261
+ syncSized(updatedNode.attrs.width)
236
262
  }
237
263
 
238
264
  return updated
@@ -0,0 +1,200 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { Extension } from '@tiptap/core'
3
+ import { Editor } from '@tiptap/vue-3'
4
+ import { Document } from '@tiptap/extension-document'
5
+ import { Paragraph } from '@tiptap/extension-paragraph'
6
+ import { Text } from '@tiptap/extension-text'
7
+ import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
8
+ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
9
+ import {
10
+ applyImage,
11
+ ImageDialogAttrs,
12
+ initialSizeMode,
13
+ removeImage,
14
+ resizeSide,
15
+ selectedImageAttrs,
16
+ sizeForMode,
17
+ textColumnWidth,
18
+ toSize,
19
+ } from '@tiptapify/extensions/components/media/image/imageDialog'
20
+
21
+ let editor: Editor | null = null
22
+
23
+ // A host attribute the dialog does not know, as hosts add with global attributes
24
+ const MediaId = Extension.create({
25
+ name: 'mediaId',
26
+ addGlobalAttributes() {
27
+ return [{
28
+ types: ['image'],
29
+ attributes: {
30
+ mediaId: {
31
+ default: null,
32
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-media-id'),
33
+ renderHTML: (attrs: Record<string, unknown>) => (attrs.mediaId ? { 'data-media-id': attrs.mediaId } : {}),
34
+ },
35
+ },
36
+ }]
37
+ },
38
+ })
39
+
40
+ function createEditor(content: string) {
41
+ editor = new Editor({
42
+ element: document.createElement('div'),
43
+ content,
44
+ extensions: [Document, Paragraph, Text, TiptapifyImage, ImageWithCaption, MediaId],
45
+ })
46
+
47
+ return editor
48
+ }
49
+
50
+ function dialogAttrs(overrides: Partial<ImageDialogAttrs> = {}): ImageDialogAttrs {
51
+ return { src: 'https://example.com/b.png', alt: 'Kyiv', title: null, width: null, height: null, align: null, ...overrides }
52
+ }
53
+
54
+ function images() {
55
+ return editor!.getJSON().content!.filter(node => node.type === 'image')
56
+ }
57
+
58
+ describe('image dialog apply', () => {
59
+ afterEach(() => {
60
+ editor?.destroy()
61
+ editor = null
62
+ })
63
+
64
+ it('updates the selected image in place, keeping the attributes the dialog does not change', () => {
65
+ createEditor('<p>Before</p><img src="https://example.com/a.png" style="float: right" data-media-id="7">')
66
+ editor!.commands.setNodeSelection(8)
67
+
68
+ const applied = applyImage(editor!, dialogAttrs({ align: 'right', title: ' Kyiv at night ', width: 640, height: 480 }))
69
+
70
+ expect(applied).toBe(true)
71
+ expect(images()).toHaveLength(1)
72
+ expect(images()[0].attrs).toMatchObject({
73
+ src: 'https://example.com/b.png',
74
+ alt: 'Kyiv',
75
+ title: 'Kyiv at night',
76
+ width: 640,
77
+ height: 480,
78
+ align: 'right',
79
+ mediaId: '7',
80
+ })
81
+ expect(editor!.state.selection.from).toBe(8)
82
+ })
83
+
84
+ it('keeps the image align when the dialog sends it back unchanged', () => {
85
+ createEditor('<img src="https://example.com/a.png" style="float: left">')
86
+ editor!.commands.setNodeSelection(0)
87
+ const current = selectedImageAttrs(editor!.state)!
88
+
89
+ applyImage(editor!, dialogAttrs({ src: current.src as string, align: current.align as 'left' }))
90
+
91
+ expect(images()[0].attrs!.align).toBe('left')
92
+ expect(editor!.getHTML()).toContain('float: left')
93
+ })
94
+
95
+ it('stores the size as numbers', () => {
96
+ createEditor('<img src="https://example.com/a.png">')
97
+ editor!.commands.setNodeSelection(0)
98
+
99
+ applyImage(editor!, dialogAttrs({ width: '640' as unknown as number, height: 359.6 }))
100
+
101
+ expect(images()[0].attrs).toMatchObject({ width: 640, height: 360 })
102
+ })
103
+
104
+ it('inserts a new image when no image is selected', () => {
105
+ createEditor('<p>Text</p>')
106
+ editor!.commands.setTextSelection(5)
107
+
108
+ const applied = applyImage(editor!, dialogAttrs({ alt: ' ', align: 'left', width: 320, height: 240 }))
109
+
110
+ expect(applied).toBe(true)
111
+ expect(images()).toHaveLength(1)
112
+ expect(images()[0].attrs).toMatchObject({ src: 'https://example.com/b.png', alt: null, align: 'left', width: 320, height: 240 })
113
+ })
114
+
115
+ it('does nothing without a source', () => {
116
+ createEditor('<p>Text</p>')
117
+
118
+ expect(applyImage(editor!, dialogAttrs({ src: ' ' }))).toBe(false)
119
+ expect(images()).toHaveLength(0)
120
+ })
121
+
122
+ it('removes the selected image only', () => {
123
+ createEditor('<p>Text</p><img src="https://example.com/a.png">')
124
+
125
+ editor!.commands.setTextSelection(2)
126
+ expect(removeImage(editor!)).toBe(false)
127
+
128
+ editor!.commands.setNodeSelection(6)
129
+ expect(removeImage(editor!)).toBe(true)
130
+ expect(images()).toHaveLength(0)
131
+ })
132
+
133
+ it('reads the attributes of the selected image only', () => {
134
+ createEditor('<p>Text</p><img src="https://example.com/a.png" width="640">')
135
+
136
+ editor!.commands.setTextSelection({ from: 1, to: editor!.state.doc.content.size })
137
+ expect(selectedImageAttrs(editor!.state)).toBeNull()
138
+
139
+ editor!.commands.setNodeSelection(6)
140
+ expect(selectedImageAttrs(editor!.state)).toMatchObject({ src: 'https://example.com/a.png', width: 640 })
141
+ })
142
+
143
+ it('sends the selected image to the dialog, or none to insert one', () => {
144
+ createEditor('<p>Text</p><img src="https://example.com/a.png">')
145
+ const details: Array<{ image: Record<string, unknown> | null }> = []
146
+ const listener = (event: Event) => details.push((event as CustomEvent).detail)
147
+ window.addEventListener('tiptapify-show-tiptapifyImage', listener)
148
+
149
+ editor!.commands.setTextSelection(2)
150
+ editor!.commands.showTiptapifyImage()
151
+ editor!.commands.setNodeSelection(6)
152
+ editor!.commands.showTiptapifyImage()
153
+ window.removeEventListener('tiptapify-show-tiptapifyImage', listener)
154
+
155
+ expect(details[0].image).toBeNull()
156
+ expect(details[1].image).toMatchObject({ src: 'https://example.com/a.png' })
157
+ })
158
+ })
159
+
160
+ describe('image dialog size', () => {
161
+ it('reads sizes as positive whole numbers', () => {
162
+ expect(toSize('640')).toBe(640)
163
+ expect(toSize(359.6)).toBe(360)
164
+ expect(toSize('')).toBeNull()
165
+ expect(toSize(0)).toBeNull()
166
+ expect(toSize(null)).toBeNull()
167
+ })
168
+
169
+ it('opens an image with a width as custom and one without as original', () => {
170
+ expect(initialSizeMode({ width: 640, height: 480 })).toBe('custom')
171
+ expect(initialSizeMode({ width: null })).toBe('original')
172
+ expect(initialSizeMode(null)).toBe('original')
173
+ })
174
+
175
+ it('sizes the image to its natural size or to the text width in its ratio', () => {
176
+ const natural = { width: 1600, height: 900 }
177
+
178
+ expect(sizeForMode('original', natural, 720)).toEqual({ width: 1600, height: 900 })
179
+ expect(sizeForMode('original', null, 720)).toEqual({ width: null, height: null })
180
+ expect(sizeForMode('text', natural, 720)).toEqual({ width: 720, height: 405 })
181
+ expect(sizeForMode('text', null, 720)).toEqual({ width: 720, height: null })
182
+ })
183
+
184
+ it('keeps the ratio when one side changes', () => {
185
+ const size = { width: 1600, height: 900 }
186
+
187
+ expect(resizeSide(size, 'width', '800', 16 / 9)).toEqual({ width: 800, height: 450 })
188
+ expect(resizeSide(size, 'height', 450, 16 / 9)).toEqual({ width: 800, height: 450 })
189
+ expect(resizeSide(size, 'width', 800, null)).toEqual({ width: 800, height: 900 })
190
+ expect(resizeSide(size, 'width', '', 16 / 9)).toEqual({ width: null, height: 900 })
191
+ })
192
+
193
+ it('measures the text column without the editor padding', () => {
194
+ const element = document.createElement('div')
195
+ element.style.padding = '0 24px'
196
+ Object.defineProperty(element, 'clientWidth', { value: 768 })
197
+
198
+ expect(textColumnWidth(element)).toBe(720)
199
+ })
200
+ })
@@ -0,0 +1,114 @@
1
+ import { Editor } from '@tiptap/core'
2
+ import { MediaSize, resizeSide, toSize } from '@tiptapify/utils/mediaSize'
3
+ import { EditorState, NodeSelection } from '@tiptap/pm/state'
4
+ import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
5
+
6
+ export { resizeSide, toSize }
7
+
8
+ export type ImageSizeMode = 'original' | 'text' | 'custom'
9
+
10
+ export type ImageSize = MediaSize
11
+
12
+ export type NaturalSize = { width: number, height: number }
13
+
14
+ // The attributes the image dialog edits; any other attribute of the image is kept as it is
15
+ export type ImageDialogAttrs = {
16
+ src: string,
17
+ alt: string | null,
18
+ title: string | null,
19
+ width: number | null,
20
+ height: number | null,
21
+ align: ImageAlign | null,
22
+ }
23
+
24
+ const typeName = 'image'
25
+
26
+ /**
27
+ * The attributes of the image selected as a node, or null when no image is selected (the dialog inserts one).
28
+ */
29
+ export function selectedImageAttrs(state: EditorState): Record<string, unknown> | null {
30
+ const { selection } = state
31
+ if (!(selection instanceof NodeSelection) || selection.node.type.name !== typeName) {
32
+ return null
33
+ }
34
+
35
+ return { ...selection.node.attrs }
36
+ }
37
+
38
+ /**
39
+ * An image with a set width opens with its size as custom; one without a width is shown at its original size.
40
+ */
41
+ export function initialSizeMode(attrs: Record<string, unknown> | null): ImageSizeMode {
42
+ return toSize(attrs?.width) ? 'custom' : 'original'
43
+ }
44
+
45
+ /**
46
+ * The width of the editor's text column: the editor content box without its horizontal padding.
47
+ */
48
+ export function textColumnWidth(element: HTMLElement): number | null {
49
+ const style = getComputedStyle(element)
50
+ const width = element.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0)
51
+
52
+ return toSize(width)
53
+ }
54
+
55
+ /**
56
+ * The image size of a size mode other than custom: the natural size, or the text column width
57
+ * with the height kept in the natural ratio.
58
+ */
59
+ export function sizeForMode(mode: Exclude<ImageSizeMode, 'custom'>, natural: NaturalSize | null, textWidth: number | null): ImageSize {
60
+ if (mode === 'original') {
61
+ return natural ? { ...natural } : { width: null, height: null }
62
+ }
63
+
64
+ if (!textWidth) {
65
+ return { width: null, height: null }
66
+ }
67
+
68
+ return { width: textWidth, height: natural ? toSize(textWidth * natural.height / natural.width) : null }
69
+ }
70
+
71
+ /**
72
+ * Updates the selected image in place, so the attributes the dialog does not know survive; inserts one when none is selected.
73
+ */
74
+ export function applyImage(editor: Editor, attrs: ImageDialogAttrs): boolean {
75
+ const values = {
76
+ ...attrs,
77
+ src: attrs.src.trim(),
78
+ alt: attrs.alt?.trim() || null,
79
+ title: attrs.title?.trim() || null,
80
+ width: toSize(attrs.width),
81
+ height: toSize(attrs.height),
82
+ }
83
+ if (!values.src) {
84
+ return false
85
+ }
86
+
87
+ if (selectedImageAttrs(editor.state)) {
88
+ return editor.chain().focus().updateAttributes(typeName, values).run()
89
+ }
90
+
91
+ return editor.chain().focus().insertContent({ type: typeName, attrs: values }).run()
92
+ }
93
+
94
+ export function removeImage(editor: Editor): boolean {
95
+ if (!selectedImageAttrs(editor.state)) {
96
+ return false
97
+ }
98
+
99
+ return editor.chain().focus().deleteSelection().run()
100
+ }
101
+
102
+ /**
103
+ * The natural size of an image, or null when it does not load.
104
+ */
105
+ export function loadNaturalSize(src: string): Promise<NaturalSize | null> {
106
+ return new Promise((resolve) => {
107
+ const image = new Image()
108
+ image.onload = () => resolve(image.naturalWidth && image.naturalHeight
109
+ ? { width: image.naturalWidth, height: image.naturalHeight }
110
+ : null)
111
+ image.onerror = () => resolve(null)
112
+ image.src = src
113
+ })
114
+ }
@@ -1,4 +1,5 @@
1
1
  import { Extension } from '@tiptap/core'
2
+ import { selectedImageAttrs } from '@tiptapify/extensions/components/media/image/imageDialog'
2
3
 
3
4
  const name: string = 'tiptapifyImage'
4
5
 
@@ -18,7 +19,7 @@ export const TiptapifyImage = Extension.create({
18
19
  showTiptapifyImage: () => ({ editor }) => {
19
20
  const event = new CustomEvent(`tiptapify-show-${name}`, {
20
21
  detail: {
21
- image: editor.getAttributes('image'),
22
+ image: selectedImageAttrs(editor.state),
22
23
  editorId: editor.instanceId
23
24
  }
24
25
  })
@@ -0,0 +1,96 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { App, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { Document } from '@tiptap/extension-document'
6
+ import { Paragraph } from '@tiptap/extension-paragraph'
7
+ import { Text } from '@tiptap/extension-text'
8
+ import { Link } from '@tiptap/extension-link'
9
+ import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
10
+ import LinkButton from '@tiptapify/extensions/components/media/link/Button.vue'
11
+ import { LinkCard } from '@tiptapify/extensions/components/media/linkCard'
12
+ import { TIPTAPIFY_LINK_PREVIEW_KEY } from '@tiptapify/composables/useTiptapifyLinkPreview'
13
+
14
+ const cardHtml = '<a class="tiptapify-link-card" data-link-card="compact" href="https://news.example.com/a">'
15
+ + '<span class="tiptapify-link-card__body"><span class="tiptapify-link-card__title">Talks</span></span></a>'
16
+
17
+ // jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
18
+ if (!('visualViewport' in globalThis)) {
19
+ ;(globalThis as Record<string, unknown>).visualViewport = {
20
+ scale: 1,
21
+ offsetLeft: 0,
22
+ offsetTop: 0,
23
+ addEventListener: () => {},
24
+ removeEventListener: () => {},
25
+ }
26
+ }
27
+
28
+ // Nor `ResizeObserver`, which the description field of the opened dialog reads
29
+ if (!('ResizeObserver' in globalThis)) {
30
+ ;(globalThis as Record<string, unknown>).ResizeObserver = class {
31
+ observe() {}
32
+ unobserve() {}
33
+ disconnect() {}
34
+ }
35
+ }
36
+
37
+ let app: App | null = null
38
+ let editor: Editor | null = null
39
+ let root: HTMLElement
40
+
41
+ async function flush() {
42
+ for (let i = 0; i < 5; i++) {
43
+ await nextTick()
44
+ }
45
+ }
46
+
47
+ async function mountButton(content: string) {
48
+ editor = new Editor({
49
+ element: document.createElement('div'),
50
+ content,
51
+ extensions: [Document, Paragraph, Text, Link.configure({ openOnClick: false }), TiptapifyLink, LinkCard],
52
+ })
53
+
54
+ app = createApp({
55
+ setup() {
56
+ provide('tiptapifyEditor', shallowRef(editor))
57
+ provide('tiptapifyI18n', { t: (key: string) => key })
58
+ provide(TIPTAPIFY_LINK_PREVIEW_KEY, { options: ref(null), available: ref(false) })
59
+
60
+ return () => h(LinkButton)
61
+ },
62
+ })
63
+ app.use(createVuetify())
64
+ root = document.body.appendChild(document.createElement('div'))
65
+ app.mount(root)
66
+ await flush()
67
+ }
68
+
69
+ afterEach(() => {
70
+ app?.unmount()
71
+ editor?.destroy()
72
+ app = null
73
+ editor = null
74
+ document.body.innerHTML = ''
75
+ })
76
+
77
+ describe('link toolbar button', () => {
78
+ it('is active while a link card is selected and opens the link dialog with it', async () => {
79
+ await mountButton(`<p>Text</p>${cardHtml}`)
80
+ editor!.commands.setTextSelection(1)
81
+ await flush()
82
+
83
+ expect(root.querySelector('.v-btn')!.className).not.toContain('primary')
84
+
85
+ // The editor state reaches Vue after two animation frames
86
+ editor!.commands.setNodeSelection(6)
87
+ await vi.waitFor(() => expect(root.querySelector('.v-btn')!.className).toContain('primary'))
88
+
89
+ const listener = vi.fn()
90
+ window.addEventListener('tiptapify-show-link', listener)
91
+ root.querySelector<HTMLButtonElement>('.v-btn')!.click()
92
+ window.removeEventListener('tiptapify-show-link', listener)
93
+
94
+ expect(listener.mock.calls[0][0].detail.card).toMatchObject({ href: 'https://news.example.com/a', title: 'Talks' })
95
+ })
96
+ })
@@ -4,6 +4,7 @@ import * as mdi from '@mdi/js'
4
4
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
5
5
  import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
6
  import LinkDialog from '@tiptapify/extensions/components/media/link/LinkDialog.vue'
7
+ import { selectedLinkCard } from '@tiptapify/extensions/components/media/linkCard'
7
8
  import { TiptapifyEditor } from '@tiptapify/types/editor'
8
9
  import { computed, inject, Ref } from 'vue'
9
10
 
@@ -16,13 +17,16 @@ const editor = inject('tiptapifyEditor') as Ref<TiptapifyEditor>
16
17
 
17
18
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
18
19
 
19
- const icon = computed(() => editor.value.isActive('tiptapifyLink') ? `mdiSvg:${mdi.mdiLinkOff}` : `mdiSvg:${mdi.mdiLink}`)
20
+ const icon = computed(() => editor.value.isActive('link') ? `mdiSvg:${mdi.mdiLinkOff}` : `mdiSvg:${mdi.mdiLink}`)
21
+
22
+ // A selected link card is edited by the link dialog too
23
+ const active = computed(() => editor.value.isActive('link') || selectedLinkCard(editor.value) !== null)
20
24
 
21
25
  </script>
22
26
 
23
27
  <template>
24
28
  <VBtn
25
- :color="editor.isActive('link') ? 'primary' : ''"
29
+ :color="active ? 'primary' : ''"
26
30
  :variant="variantBtn"
27
31
  size="32"
28
32
  @click="editor.commands.showLink()"