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.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110820 -65669
- 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 +72 -2
- 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
|
-
|
|
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.
|
|
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('
|
|
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="
|
|
29
|
+
:color="active ? 'primary' : ''"
|
|
26
30
|
:variant="variantBtn"
|
|
27
31
|
size="32"
|
|
28
32
|
@click="editor.commands.showLink()"
|