tiptapify 0.3.1 → 0.3.3
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/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +54124 -48070
- package/dist/tiptapify.umd.js +59 -59
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/Footer.vue +12 -2
- package/src/components/ImageBubbleMenu.vue +130 -0
- package/src/components/MenuBubble.vue +36 -21
- package/src/components/Tiptapify.vue +189 -72
- package/src/components/Toolbar/Index.test.ts +96 -0
- package/src/components/Toolbar/Index.vue +225 -14
- package/src/components/Toolbar/Items.test.ts +49 -3
- package/src/components/Toolbar/Items.vue +25 -26
- package/src/components/Toolbar/alignment.ts +5 -0
- package/src/components/UI/TiptapifyDialog.vue +78 -12
- package/src/components/editorExtensions.test.ts +10 -0
- package/src/components/editorExtensions.ts +34 -5
- package/src/components/index.ts +3 -2
- package/src/extensions/components/actions/redo/Button.vue +1 -1
- package/src/extensions/components/actions/undo/Button.vue +1 -1
- package/src/extensions/components/alignment/menu/Button.vue +68 -0
- package/src/extensions/components/list/bullet/Button.vue +16 -1
- package/src/extensions/components/list/numbered/Button.vue +19 -0
- package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
- package/src/extensions/components/list/styles/index.ts +76 -0
- package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
- package/src/extensions/components/media/image/ImageDialog.vue +1 -1
- package/src/extensions/components/media/image/caption.test.ts +122 -0
- package/src/extensions/components/media/image/caption.ts +268 -0
- package/src/extensions/components/media/image/slider.test.ts +106 -0
- package/src/extensions/components/media/image/slider.ts +87 -0
- package/src/extensions/components/style/heading/Button.vue +75 -35
- package/src/i18n/locales/ar.json +22 -0
- package/src/i18n/locales/bg.json +22 -0
- package/src/i18n/locales/ch.json +22 -0
- package/src/i18n/locales/cz.json +22 -0
- package/src/i18n/locales/de.json +22 -0
- package/src/i18n/locales/el.json +22 -0
- package/src/i18n/locales/en.json +23 -1
- package/src/i18n/locales/es.json +22 -0
- package/src/i18n/locales/et.json +22 -0
- package/src/i18n/locales/fi.json +22 -0
- package/src/i18n/locales/fr.json +22 -0
- package/src/i18n/locales/he.json +22 -0
- package/src/i18n/locales/hi.json +22 -0
- package/src/i18n/locales/hr.json +22 -0
- package/src/i18n/locales/hu.json +22 -0
- package/src/i18n/locales/id.json +22 -0
- package/src/i18n/locales/it.json +22 -0
- package/src/i18n/locales/ja.json +22 -0
- package/src/i18n/locales/ko.json +22 -0
- package/src/i18n/locales/la.json +22 -0
- package/src/i18n/locales/lt.json +22 -0
- package/src/i18n/locales/nl.json +22 -0
- package/src/i18n/locales/no.json +22 -0
- package/src/i18n/locales/pl.json +22 -0
- package/src/i18n/locales/pt-BR.json +22 -0
- package/src/i18n/locales/pt.json +22 -0
- package/src/i18n/locales/ro.json +22 -0
- package/src/i18n/locales/ru.json +23 -1
- package/src/i18n/locales/se.json +22 -0
- package/src/i18n/locales/sk.json +22 -0
- package/src/i18n/locales/sl.json +22 -0
- package/src/i18n/locales/sr.json +22 -0
- package/src/i18n/locales/th.json +22 -0
- package/src/i18n/locales/tr.json +22 -0
- package/src/i18n/locales/uk.json +23 -1
- package/src/i18n/locales/vi.json +22 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } 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 { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
|
|
7
|
+
import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
|
|
8
|
+
|
|
9
|
+
let editor: Editor | null = null
|
|
10
|
+
|
|
11
|
+
function createEditor(content: string) {
|
|
12
|
+
editor = new Editor({
|
|
13
|
+
element: document.createElement('div'),
|
|
14
|
+
content,
|
|
15
|
+
extensions: [
|
|
16
|
+
Document,
|
|
17
|
+
Paragraph,
|
|
18
|
+
Text,
|
|
19
|
+
TiptapifyImage,
|
|
20
|
+
ImageWithCaption.configure({
|
|
21
|
+
resize: { enabled: true, alwaysPreserveAspectRatio: true },
|
|
22
|
+
captionPlaceholder: 'Add a caption',
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return editor
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function caption(): HTMLInputElement {
|
|
31
|
+
return editor!.view.dom.querySelector('.tiptapify-image-caption') as HTMLInputElement
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function typeCaption(value: string) {
|
|
35
|
+
caption().value = value
|
|
36
|
+
caption().dispatchEvent(new Event('input'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('image caption', () => {
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
editor?.destroy()
|
|
42
|
+
editor = null
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('shows the image title as the caption under the image', () => {
|
|
46
|
+
createEditor('<img src="https://example.com/a.png" title="Kyiv at night">')
|
|
47
|
+
|
|
48
|
+
expect(caption().value).toBe('Kyiv at night')
|
|
49
|
+
expect(caption().placeholder).toBe('Add a caption')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('saves the typed caption as the figure caption', () => {
|
|
53
|
+
createEditor('<img src="https://example.com/a.png">')
|
|
54
|
+
|
|
55
|
+
typeCaption('Kyiv at night')
|
|
56
|
+
|
|
57
|
+
expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"><figcaption>Kyiv at night</figcaption></figure>')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('saves an image without a caption as a figure without figcaption', () => {
|
|
61
|
+
createEditor('<img src="https://example.com/a.png" title="Kyiv at night">')
|
|
62
|
+
|
|
63
|
+
typeCaption(' ')
|
|
64
|
+
|
|
65
|
+
expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"></figure>')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('loads a figure with its caption and image attributes', () => {
|
|
69
|
+
createEditor('<figure><img src="https://example.com/a.png" alt="Kyiv" width="640"><figcaption> Kyiv at night </figcaption></figure>')
|
|
70
|
+
|
|
71
|
+
expect(caption().value).toBe('Kyiv at night')
|
|
72
|
+
expect(editor!.getJSON().content![0].attrs).toMatchObject({ src: 'https://example.com/a.png', alt: 'Kyiv', width: 640, title: 'Kyiv at night' })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps consecutive figures as separate images', () => {
|
|
76
|
+
createEditor('<figure><img src="https://example.com/a.png"></figure><figure><img src="https://example.com/b.png"></figure>')
|
|
77
|
+
|
|
78
|
+
expect(editor!.getJSON().content!.map(node => node.type)).toEqual(['image', 'image'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('updates the caption when the title is changed elsewhere', () => {
|
|
82
|
+
createEditor('<img src="https://example.com/a.png">')
|
|
83
|
+
|
|
84
|
+
editor!.chain().setNodeSelection(0).updateAttributes('image', { title: 'From the dialog' }).run()
|
|
85
|
+
|
|
86
|
+
expect(caption().value).toBe('From the dialog')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('opens the image dialog for this image on double click', () => {
|
|
90
|
+
createEditor('<p>text</p><img src="https://example.com/a.png" alt="Kyiv">')
|
|
91
|
+
const listener = vi.fn()
|
|
92
|
+
window.addEventListener('tiptapify-show-tiptapifyImage', listener)
|
|
93
|
+
|
|
94
|
+
editor!.view.dom.querySelector('[data-resize-wrapper]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
95
|
+
|
|
96
|
+
window.removeEventListener('tiptapify-show-tiptapifyImage', listener)
|
|
97
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
98
|
+
expect(listener.mock.calls[0][0].detail.image).toMatchObject({ src: 'https://example.com/a.png', alt: 'Kyiv' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('moves the cursor to the text after the image on Enter', () => {
|
|
102
|
+
createEditor('<img src="https://example.com/a.png"><p>after</p>')
|
|
103
|
+
|
|
104
|
+
caption().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
|
105
|
+
|
|
106
|
+
const { $from } = editor!.state.selection
|
|
107
|
+
expect($from.parent.textContent).toBe('after')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('floats the image with the align attribute', () => {
|
|
111
|
+
createEditor('<img src="https://example.com/a.png" style="float: right">')
|
|
112
|
+
const nodeView = editor!.view.nodeDOM(0) as HTMLElement
|
|
113
|
+
|
|
114
|
+
expect(nodeView.dataset.align).toBe('right')
|
|
115
|
+
expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png" style="float: right;"></figure>')
|
|
116
|
+
|
|
117
|
+
editor!.chain().setNodeSelection(0).updateAttributes('image', { align: null }).run()
|
|
118
|
+
|
|
119
|
+
expect(nodeView.dataset.align).toBeUndefined()
|
|
120
|
+
expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"></figure>')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { Image, ImageOptions } from '@tiptap/extension-image'
|
|
2
|
+
import { Editor, fromString, mergeAttributes, NodeViewRendererProps } from '@tiptap/core'
|
|
3
|
+
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
4
|
+
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
|
5
|
+
|
|
6
|
+
export type ImageAlign = 'left' | 'right'
|
|
7
|
+
|
|
8
|
+
declare module '@tiptap/core' {
|
|
9
|
+
interface Commands<ReturnType> {
|
|
10
|
+
imageWithCaption: {
|
|
11
|
+
// Swaps the selected image with the image right before (-1) or after (1) it, as slides of a slider
|
|
12
|
+
moveImage: (direction: -1 | 1) => ReturnType
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImageWithCaptionOptions extends ImageOptions {
|
|
18
|
+
captionPlaceholder: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The image with its caption typed right under it, saved as
|
|
23
|
+
* `<figure><img src alt><figcaption>caption</figcaption></figure>`. In the document the caption is
|
|
24
|
+
* the image `title` attribute, so a legacy `<img title>` loads with its title as the caption.
|
|
25
|
+
* The image floats left or right with `align`, saved as `float` in the image style (what sites read
|
|
26
|
+
* from TinyMCE images); without it the image is centered. A double click opens the image dialog.
|
|
27
|
+
*/
|
|
28
|
+
export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
|
|
29
|
+
addOptions() {
|
|
30
|
+
return {
|
|
31
|
+
...this.parent?.() as ImageOptions,
|
|
32
|
+
captionPlaceholder: '',
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
addAttributes() {
|
|
37
|
+
return {
|
|
38
|
+
...this.parent?.(),
|
|
39
|
+
align: {
|
|
40
|
+
default: null,
|
|
41
|
+
parseHTML: (element: HTMLElement) => (['left', 'right'].includes(element.style.float) ? element.style.float : null),
|
|
42
|
+
// Rendered by renderHTML onto the image only: in the editor the node view floats the whole figure
|
|
43
|
+
renderHTML: () => ({}),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
parseHTML() {
|
|
49
|
+
const { editor, name, options } = this
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
tag: 'figure',
|
|
54
|
+
getAttrs: (figure: HTMLElement) => {
|
|
55
|
+
const img = figure.querySelector('img[src]')
|
|
56
|
+
if (!img || (!options.allowBase64 && img.getAttribute('src')!.startsWith('data:'))) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const attrs = parseImageAttributes(editor as Editor, name, img as HTMLElement)
|
|
61
|
+
const caption = figure.querySelector('figcaption')?.textContent?.trim()
|
|
62
|
+
|
|
63
|
+
return { ...attrs, title: caption || attrs.title || null }
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
...(this.parent?.() ?? []),
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
71
|
+
const align = node.attrs.align as ImageAlign | null
|
|
72
|
+
const { title, ...imageAttributes } = mergeAttributes(
|
|
73
|
+
this.options.HTMLAttributes,
|
|
74
|
+
HTMLAttributes,
|
|
75
|
+
align ? { style: `float: ${align}` } : {},
|
|
76
|
+
)
|
|
77
|
+
const figure: [string, Record<string, unknown>, ...unknown[]] = ['figure', {}, ['img', imageAttributes]]
|
|
78
|
+
if (title) {
|
|
79
|
+
figure.push(['figcaption', {}, String(title)])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return figure as never
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
addCommands() {
|
|
86
|
+
return {
|
|
87
|
+
...this.parent?.(),
|
|
88
|
+
moveImage: (direction: -1 | 1) => ({ state, tr, dispatch }) => {
|
|
89
|
+
const { selection } = state
|
|
90
|
+
if (!(selection instanceof NodeSelection) || selection.node.type.name !== this.name) {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const $image = state.doc.resolve(selection.from)
|
|
95
|
+
const siblingIndex = $image.index() + direction
|
|
96
|
+
if (siblingIndex < 0 || siblingIndex >= $image.parent.childCount) {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sibling = $image.parent.child(siblingIndex)
|
|
101
|
+
if (sibling.type !== selection.node.type) {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (dispatch) {
|
|
106
|
+
const image = selection.node
|
|
107
|
+
if (direction < 0) {
|
|
108
|
+
const from = selection.from - sibling.nodeSize
|
|
109
|
+
tr.replaceWith(from, selection.to, [image, sibling])
|
|
110
|
+
tr.setSelection(NodeSelection.create(tr.doc, from))
|
|
111
|
+
} else {
|
|
112
|
+
tr.replaceWith(selection.from, selection.to + sibling.nodeSize, [sibling, image])
|
|
113
|
+
tr.setSelection(NodeSelection.create(tr.doc, selection.from + sibling.nodeSize))
|
|
114
|
+
}
|
|
115
|
+
dispatch(tr.scrollIntoView())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return true
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
addNodeView() {
|
|
124
|
+
const createResizableView = this.parent?.()
|
|
125
|
+
if (!createResizableView) {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { captionPlaceholder } = this.options
|
|
130
|
+
|
|
131
|
+
return (props: NodeViewRendererProps) => {
|
|
132
|
+
const nodeView = createResizableView(props) as ReturnType<typeof createResizableView> & {
|
|
133
|
+
dom: HTMLElement
|
|
134
|
+
update: (node: ProseMirrorNode, ...args: unknown[]) => boolean
|
|
135
|
+
stopEvent?: (event: Event) => boolean
|
|
136
|
+
ignoreMutation?: (mutation: MutationRecord) => boolean
|
|
137
|
+
}
|
|
138
|
+
const editor = props.editor as Editor
|
|
139
|
+
const getPos = props.getPos as () => number | undefined
|
|
140
|
+
let node = props.node
|
|
141
|
+
|
|
142
|
+
const caption = document.createElement('input')
|
|
143
|
+
caption.type = 'text'
|
|
144
|
+
caption.className = 'tiptapify-image-caption'
|
|
145
|
+
caption.size = 1
|
|
146
|
+
caption.placeholder = captionPlaceholder
|
|
147
|
+
caption.value = node.attrs.title ?? ''
|
|
148
|
+
caption.disabled = !editor.isEditable
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
const wrapper = nodeView.dom.querySelector('[data-resize-wrapper]') ?? nodeView.dom
|
|
152
|
+
const syncAlign = (align: ImageAlign | null) => {
|
|
153
|
+
if (align) {
|
|
154
|
+
nodeView.dom.dataset.align = align
|
|
155
|
+
} else {
|
|
156
|
+
delete nodeView.dom.dataset.align
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
syncAlign(node.attrs.align)
|
|
160
|
+
|
|
161
|
+
// The aspect ratio lets CSS cap a tall image by its height without distorting it
|
|
162
|
+
const img = nodeView.dom.querySelector('img') as HTMLImageElement
|
|
163
|
+
const setAspectRatio = () => {
|
|
164
|
+
if (img.naturalWidth && img.naturalHeight) {
|
|
165
|
+
nodeView.dom.style.setProperty('--tiptapify-image-ratio', String(img.naturalWidth / img.naturalHeight))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
img.addEventListener('load', setAspectRatio)
|
|
169
|
+
setAspectRatio()
|
|
170
|
+
nodeView.dom.classList.add('tiptapify-image')
|
|
171
|
+
nodeView.dom.append(caption)
|
|
172
|
+
|
|
173
|
+
const openDialog = () => {
|
|
174
|
+
const pos = getPos()
|
|
175
|
+
if (pos === undefined) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The dialog reads the selected image, so the selection must be applied before it opens
|
|
180
|
+
editor.commands.setNodeSelection(pos)
|
|
181
|
+
editor.commands.showTiptapifyImage()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const saveCaption = () => {
|
|
185
|
+
const pos = getPos()
|
|
186
|
+
const title = caption.value.trim() === '' ? null : caption.value
|
|
187
|
+
if (pos === undefined || title === (node.attrs.title ?? null)) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
editor.view.dispatch(editor.state.tr.setNodeAttribute(pos, 'title', title))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Enter leaves the caption for the text after the image
|
|
195
|
+
const leaveCaption = () => {
|
|
196
|
+
const pos = getPos()
|
|
197
|
+
if (pos === undefined) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const after = pos + node.nodeSize
|
|
202
|
+
const { state } = editor
|
|
203
|
+
const selection = TextSelection.near(state.doc.resolve(Math.min(after, state.doc.content.size)))
|
|
204
|
+
editor.view.dispatch(state.tr.setSelection(selection))
|
|
205
|
+
editor.view.focus()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
caption.addEventListener('input', saveCaption)
|
|
209
|
+
caption.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
210
|
+
if (event.key === 'Enter' || event.key === 'Escape') {
|
|
211
|
+
event.preventDefault()
|
|
212
|
+
leaveCaption()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
// Selecting caption text with the mouse must not start dragging the whole image
|
|
216
|
+
caption.addEventListener('mousedown', () => {
|
|
217
|
+
nodeView.dom.draggable = false
|
|
218
|
+
})
|
|
219
|
+
caption.addEventListener('blur', () => {
|
|
220
|
+
nodeView.dom.draggable = true
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
wrapper.addEventListener('dblclick', openDialog)
|
|
224
|
+
|
|
225
|
+
const updateResizableView = nodeView.update.bind(nodeView)
|
|
226
|
+
nodeView.update = (updatedNode: ProseMirrorNode, ...args: unknown[]) => {
|
|
227
|
+
const updated = updateResizableView(updatedNode, ...args)
|
|
228
|
+
if (updated) {
|
|
229
|
+
node = updatedNode
|
|
230
|
+
const title = updatedNode.attrs.title ?? ''
|
|
231
|
+
if (caption.value !== title && document.activeElement !== caption) {
|
|
232
|
+
caption.value = title
|
|
233
|
+
}
|
|
234
|
+
caption.disabled = !editor.isEditable
|
|
235
|
+
syncAlign(updatedNode.attrs.align)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return updated
|
|
239
|
+
}
|
|
240
|
+
nodeView.stopEvent = (event: Event) => caption.contains(event.target as Node)
|
|
241
|
+
nodeView.ignoreMutation = (mutation: MutationRecord) => caption.contains(mutation.target)
|
|
242
|
+
|| (mutation.type === 'attributes' && mutation.target === nodeView.dom)
|
|
243
|
+
|
|
244
|
+
return nodeView
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The image attributes of a `<figure>` are read from its `<img>`, each one the way its extension
|
|
251
|
+
* reads it from an `<img>` (global attributes such as a media id included).
|
|
252
|
+
*/
|
|
253
|
+
function parseImageAttributes(editor: Editor, typeName: string, img: HTMLElement): Record<string, unknown> {
|
|
254
|
+
const attributes: Record<string, unknown> = {}
|
|
255
|
+
|
|
256
|
+
for (const { type, name, attribute } of editor.extensionManager.attributes) {
|
|
257
|
+
if (type !== typeName) {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const value = attribute.parseHTML ? attribute.parseHTML(img) : fromString(img.getAttribute(name))
|
|
262
|
+
if (value !== null && value !== undefined) {
|
|
263
|
+
attributes[name] = value
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return attributes
|
|
268
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { Gapcursor } from '@tiptap/extensions'
|
|
7
|
+
import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
|
|
8
|
+
import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
|
|
9
|
+
import { findImageRuns, ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
|
|
10
|
+
|
|
11
|
+
let editor: Editor | null = null
|
|
12
|
+
|
|
13
|
+
function createEditor(content: string, minImages = 3) {
|
|
14
|
+
editor = new Editor({
|
|
15
|
+
element: document.createElement('div'),
|
|
16
|
+
content,
|
|
17
|
+
extensions: [
|
|
18
|
+
Document,
|
|
19
|
+
Paragraph,
|
|
20
|
+
Text,
|
|
21
|
+
Gapcursor,
|
|
22
|
+
TiptapifyImage,
|
|
23
|
+
ImageWithCaption.configure({ resize: { enabled: true, alwaysPreserveAspectRatio: true } }),
|
|
24
|
+
ImageSlider.configure({ minImages, label: 'Slider' }),
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return editor
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const img = (name: string) => `<img src="https://example.com/${name}.png">`
|
|
32
|
+
|
|
33
|
+
function slideLabels(): Array<string | null> {
|
|
34
|
+
return [...editor!.view.dom.querySelectorAll('.tiptapify-image')]
|
|
35
|
+
.map(image => (image.classList.contains('tiptapify-slide') ? image.getAttribute('data-slide-label') : null))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('image slider', () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
editor?.destroy()
|
|
41
|
+
editor = null
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('finds runs of images with nothing between them', () => {
|
|
45
|
+
createEditor(`${img('a')}${img('b')}${img('c')}<p>text</p>${img('d')}${img('e')}`)
|
|
46
|
+
|
|
47
|
+
expect(findImageRuns(editor!.state.doc, 'image', 2).map(run => run.length)).toEqual([3, 2])
|
|
48
|
+
expect(findImageRuns(editor!.state.doc, 'image', 3).map(run => run.length)).toEqual([3])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('numbers the slides of a run that reaches the minimum', () => {
|
|
52
|
+
createEditor(`<p>text</p>${img('a')}${img('b')}${img('c')}${img('d')}<p>text</p>${img('e')}${img('f')}`)
|
|
53
|
+
|
|
54
|
+
expect(slideLabels()).toEqual(['Slider · 1/4', '2', '3', '4', null, null])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('uses the configured minimum', () => {
|
|
58
|
+
createEditor(`${img('a')}${img('b')}<p>text</p>${img('c')}`, 2)
|
|
59
|
+
|
|
60
|
+
expect(slideLabels()).toEqual(['Slider · 1/2', '2', null])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('stops the slider when text splits the run', () => {
|
|
64
|
+
createEditor(`${img('a')}${img('b')}${img('c')}`)
|
|
65
|
+
editor!.chain().insertContentAt(editor!.state.doc.child(0).nodeSize, '<p>text</p>').run()
|
|
66
|
+
|
|
67
|
+
expect(slideLabels()).toEqual([null, null, null])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps the slides while an image is selected', () => {
|
|
71
|
+
createEditor(`${img('a')}${img('b')}${img('c')}`)
|
|
72
|
+
editor!.commands.setNodeSelection(0)
|
|
73
|
+
editor!.commands.focus()
|
|
74
|
+
|
|
75
|
+
expect(slideLabels()).toEqual(['Slider · 1/3', '2', '3'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does nothing when the minimum is below two', () => {
|
|
79
|
+
createEditor(`${img('a')}${img('b')}${img('c')}`, 0)
|
|
80
|
+
|
|
81
|
+
expect(slideLabels()).toEqual([null, null, null])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('swaps a slide with the next and the previous one', () => {
|
|
85
|
+
createEditor(`${img('a')}${img('b')}${img('c')}`)
|
|
86
|
+
const sources = () => editor!.getJSON().content!.map(node => node.attrs!.src.slice(-5))
|
|
87
|
+
|
|
88
|
+
editor!.commands.setNodeSelection(0)
|
|
89
|
+
expect(editor!.commands.moveImage(1)).toBe(true)
|
|
90
|
+
expect(sources()).toEqual(['b.png', 'a.png', 'c.png'])
|
|
91
|
+
expect(editor!.getAttributes('image').src).toContain('a.png')
|
|
92
|
+
|
|
93
|
+
expect(editor!.commands.moveImage(-1)).toBe(true)
|
|
94
|
+
expect(sources()).toEqual(['a.png', 'b.png', 'c.png'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('does not move an image past the end of its run', () => {
|
|
98
|
+
createEditor(`<p>text</p>${img('a')}${img('b')}`)
|
|
99
|
+
const first = editor!.state.doc.child(0).nodeSize
|
|
100
|
+
|
|
101
|
+
editor!.commands.setNodeSelection(first)
|
|
102
|
+
|
|
103
|
+
expect(editor!.can().moveImage(-1)).toBe(false)
|
|
104
|
+
expect(editor!.can().moveImage(1)).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
3
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
5
|
+
|
|
6
|
+
export interface ImageSliderOptions {
|
|
7
|
+
// How many images in a row make a slider; below 2 the extension does nothing
|
|
8
|
+
minImages: number
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ImageRun = Array<{ from: number, to: number }>
|
|
13
|
+
|
|
14
|
+
const name = 'tiptapifyImageSlider'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs of at least `minImages` top-level images with nothing between them.
|
|
18
|
+
*/
|
|
19
|
+
export function findImageRuns(doc: ProseMirrorNode, typeName: string, minImages: number): ImageRun[] {
|
|
20
|
+
const runs: ImageRun[] = []
|
|
21
|
+
let run: ImageRun = []
|
|
22
|
+
|
|
23
|
+
const flush = () => {
|
|
24
|
+
if (run.length >= minImages) {
|
|
25
|
+
runs.push(run)
|
|
26
|
+
}
|
|
27
|
+
run = []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
doc.forEach((node, offset) => {
|
|
31
|
+
if (node.type.name !== typeName) {
|
|
32
|
+
flush()
|
|
33
|
+
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
run.push({ from: offset, to: offset + node.nodeSize })
|
|
38
|
+
})
|
|
39
|
+
flush()
|
|
40
|
+
|
|
41
|
+
return runs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Images in a row that a site shows as a slider: the editor lays them out as a numbered grid
|
|
46
|
+
* (the `tiptapify-slide` classes) so the author sees the slider while writing.
|
|
47
|
+
*/
|
|
48
|
+
export const ImageSlider = Extension.create<ImageSliderOptions>({
|
|
49
|
+
name,
|
|
50
|
+
|
|
51
|
+
addOptions() {
|
|
52
|
+
return {
|
|
53
|
+
minImages: 0,
|
|
54
|
+
label: 'Slider',
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
addProseMirrorPlugins() {
|
|
59
|
+
const { minImages, label } = this.options
|
|
60
|
+
if (minImages < 2) {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const decorate = (doc: ProseMirrorNode) => DecorationSet.create(
|
|
65
|
+
doc,
|
|
66
|
+
findImageRuns(doc, 'image', minImages).flatMap(run => run.map(({ from, to }, index) => Decoration.node(from, to, {
|
|
67
|
+
class: index === 0 ? 'tiptapify-slide tiptapify-slide--first' : 'tiptapify-slide',
|
|
68
|
+
'data-slide-label': index === 0 ? `${label} · 1/${run.length}` : String(index + 1),
|
|
69
|
+
}))),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
new Plugin({
|
|
74
|
+
key: new PluginKey(name),
|
|
75
|
+
state: {
|
|
76
|
+
init: (_, { doc }) => decorate(doc),
|
|
77
|
+
apply: (tr, decorations) => (tr.docChanged ? decorate(tr.doc) : decorations),
|
|
78
|
+
},
|
|
79
|
+
props: {
|
|
80
|
+
decorations(state) {
|
|
81
|
+
return this.getState(state)
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
})
|