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.
Files changed (68) hide show
  1. package/dist/tiptapify.css +1 -1
  2. package/dist/tiptapify.mjs +54124 -48070
  3. package/dist/tiptapify.umd.js +59 -59
  4. package/index.d.ts +1 -0
  5. package/package.json +1 -1
  6. package/src/components/Footer.vue +12 -2
  7. package/src/components/ImageBubbleMenu.vue +130 -0
  8. package/src/components/MenuBubble.vue +36 -21
  9. package/src/components/Tiptapify.vue +189 -72
  10. package/src/components/Toolbar/Index.test.ts +96 -0
  11. package/src/components/Toolbar/Index.vue +225 -14
  12. package/src/components/Toolbar/Items.test.ts +49 -3
  13. package/src/components/Toolbar/Items.vue +25 -26
  14. package/src/components/Toolbar/alignment.ts +5 -0
  15. package/src/components/UI/TiptapifyDialog.vue +78 -12
  16. package/src/components/editorExtensions.test.ts +10 -0
  17. package/src/components/editorExtensions.ts +34 -5
  18. package/src/components/index.ts +3 -2
  19. package/src/extensions/components/actions/redo/Button.vue +1 -1
  20. package/src/extensions/components/actions/undo/Button.vue +1 -1
  21. package/src/extensions/components/alignment/menu/Button.vue +68 -0
  22. package/src/extensions/components/list/bullet/Button.vue +16 -1
  23. package/src/extensions/components/list/numbered/Button.vue +19 -0
  24. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  25. package/src/extensions/components/list/styles/index.ts +76 -0
  26. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  27. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  28. package/src/extensions/components/media/image/caption.test.ts +122 -0
  29. package/src/extensions/components/media/image/caption.ts +268 -0
  30. package/src/extensions/components/media/image/slider.test.ts +106 -0
  31. package/src/extensions/components/media/image/slider.ts +87 -0
  32. package/src/extensions/components/style/heading/Button.vue +75 -35
  33. package/src/i18n/locales/ar.json +22 -0
  34. package/src/i18n/locales/bg.json +22 -0
  35. package/src/i18n/locales/ch.json +22 -0
  36. package/src/i18n/locales/cz.json +22 -0
  37. package/src/i18n/locales/de.json +22 -0
  38. package/src/i18n/locales/el.json +22 -0
  39. package/src/i18n/locales/en.json +23 -1
  40. package/src/i18n/locales/es.json +22 -0
  41. package/src/i18n/locales/et.json +22 -0
  42. package/src/i18n/locales/fi.json +22 -0
  43. package/src/i18n/locales/fr.json +22 -0
  44. package/src/i18n/locales/he.json +22 -0
  45. package/src/i18n/locales/hi.json +22 -0
  46. package/src/i18n/locales/hr.json +22 -0
  47. package/src/i18n/locales/hu.json +22 -0
  48. package/src/i18n/locales/id.json +22 -0
  49. package/src/i18n/locales/it.json +22 -0
  50. package/src/i18n/locales/ja.json +22 -0
  51. package/src/i18n/locales/ko.json +22 -0
  52. package/src/i18n/locales/la.json +22 -0
  53. package/src/i18n/locales/lt.json +22 -0
  54. package/src/i18n/locales/nl.json +22 -0
  55. package/src/i18n/locales/no.json +22 -0
  56. package/src/i18n/locales/pl.json +22 -0
  57. package/src/i18n/locales/pt-BR.json +22 -0
  58. package/src/i18n/locales/pt.json +22 -0
  59. package/src/i18n/locales/ro.json +22 -0
  60. package/src/i18n/locales/ru.json +23 -1
  61. package/src/i18n/locales/se.json +22 -0
  62. package/src/i18n/locales/sk.json +22 -0
  63. package/src/i18n/locales/sl.json +22 -0
  64. package/src/i18n/locales/sr.json +22 -0
  65. package/src/i18n/locales/th.json +22 -0
  66. package/src/i18n/locales/tr.json +22 -0
  67. package/src/i18n/locales/uk.json +23 -1
  68. package/src/i18n/locales/vi.json +22 -0
@@ -150,7 +150,7 @@ onUnmounted(() => {
150
150
  <VTextField
151
151
  v-model="attrs.title"
152
152
  density="comfortable"
153
- :label="t('dialog.image.title')"
153
+ :label="t('dialog.image.caption')"
154
154
  />
155
155
  </div>
156
156
  </div>
@@ -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
+ })