import { Editor, Extension } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import { Markdown } from '@tiptap/markdown' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' import { Image } from './image.js' describe('extension-image', () => { const editorElClass = 'tiptap' let editor: Editor | null = null const createEditorEl = () => { const editorEl = document.createElement('div') editorEl.classList.add(editorElClass) document.body.appendChild(editorEl) return editorEl } const getEditorEl = () => document.querySelector(`.${editorElClass}`) const destroyEditor = () => { editor?.destroy() editor = null getEditorEl()?.remove() } afterEach(() => { destroyEditor() }) const imgSrc = 'https://example.com/image.jpg' it('should apply HTMLAttributes via renderHTML when resize is disabled', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Image.configure({ HTMLAttributes: { class: 'my-classname', }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: imgSrc, alt: 'test-alt' }, }, ], }, }) expect(editor.getHTML()).toContain('class="my-classname"') }) it('should apply HTMLAttributes in the editor DOM when resize is disabled', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Image.configure({ HTMLAttributes: { class: 'my-classname', }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: imgSrc, alt: 'test-alt' }, }, ], }, }) const img = editor.view.dom.querySelector('img') expect(img).not.toBeNull() // ProseMirror may add additional classes like 'ProseMirror-selectednode' expect(img?.getAttribute('class')).toContain('my-classname') }) it('should apply HTMLAttributes via renderHTML when resize is enabled', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Image.configure({ HTMLAttributes: { class: 'my-classname', }, resize: { enabled: true, }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: imgSrc, alt: 'test-alt' }, }, ], }, }) // getHTML() goes through renderHTML which already correctly merges HTMLAttributes expect(editor.getHTML()).toContain('class="my-classname"') }) it('should apply HTMLAttributes in the editor DOM when resize is enabled', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Image.configure({ HTMLAttributes: { class: 'my-classname', 'data-test': 'value', }, resize: { enabled: true, }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: imgSrc, alt: 'test-alt' }, }, ], }, }) const img = editor.view.dom.querySelector('img') expect(img).not.toBeNull() expect(img?.getAttribute('class')).toBe('my-classname') expect(img?.getAttribute('data-test')).toBe('value') expect(img?.getAttribute('src')).toBe(imgSrc) }) it('should apply HTMLAttributes in the editor DOM when resize is enabled and content is markdown', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Markdown, Image.configure({ HTMLAttributes: { class: 'my-classname', }, resize: { enabled: true, }, }), ], content: '![Image](https://example.com/image.jpg)', contentType: 'markdown', }) const img = editor.view.dom.querySelector('img') expect(img).not.toBeNull() expect(img?.getAttribute('class')).toBe('my-classname') expect(img?.getAttribute('src')).toBe('https://example.com/image.jpg') }) it('should apply HTMLAttributes via renderHTML when resize is enabled and content is markdown', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Paragraph, Text, Markdown, Image.configure({ HTMLAttributes: { class: 'my-classname', }, resize: { enabled: true, }, }), ], content: '![Image](https://example.com/image.jpg)', contentType: 'markdown', }) // getHTML() goes through renderHTML which already correctly merges HTMLAttributes expect(editor.getHTML()).toContain('class="my-classname"') }) /** * Minimal extension that adds a global `customId` attribute (rendered as `data-custom-id`) * to specified node types. This simulates what UniqueID does but without the complex * plugin machinery, making it reliable in test environments. */ const CustomIdAttribute = Extension.create({ name: 'customIdAttribute', addGlobalAttributes() { return [ { types: ['image'], attributes: { customId: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-custom-id'), renderHTML: (attributes: Record) => { if (!attributes.customId) { return {} } return { 'data-custom-id': attributes.customId } }, }, }, }, ] }, }) describe('resizable image attribute updates', () => { it('should render global attributes on the resizable image element', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), CustomIdAttribute, ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', customId: 'my-id-123', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') expect(container).not.toBeNull() const img = container?.querySelector('img') expect(img).not.toBeNull() expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png') expect(img?.getAttribute('data-custom-id')).toBe('my-id-123') }) it('should update global attributes on the img element when node attributes change', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), CustomIdAttribute, ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', customId: 'original-id', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('data-custom-id')).toBe('original-id') // Update the customId so the img element reflects the new value. editor.commands.updateAttributes('image', { customId: 'updated-id' }) expect(img?.getAttribute('data-custom-id')).toBe('updated-id') }) it('should preserve global attributes when other attributes change', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), CustomIdAttribute, ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', customId: 'persistent-id', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('data-custom-id')).toBe('persistent-id') // Update alt text while retaining data-custom-id. editor.commands.updateAttributes('image', { alt: 'Updated alt text' }) expect(img?.getAttribute('alt')).toBe('Updated alt text') expect(img?.getAttribute('data-custom-id')).toBe('persistent-id') }) it('should update src on the resizable image when src attribute changes', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png') editor.commands.updateAttributes('image', { src: 'https://tiptap.dev/new-image.png' }) expect(img?.getAttribute('src')).toBe('https://tiptap.dev/new-image.png') }) it('should clear src on the resizable image when src is set to null', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png') editor.commands.updateAttributes('image', { src: null }) expect(img?.hasAttribute('src')).toBe(false) expect(img?.getAttribute('src')).toBeNull() expect(img?.src).toBe('') }) it('should clear src on the resizable image when src is set to an empty string', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png') editor.commands.updateAttributes('image', { src: '' }) expect(img?.hasAttribute('src')).toBe(false) expect(img?.getAttribute('src')).toBeNull() expect(img?.src).toBe('') }) it('should remove attributes from the resizable image when set to null', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Image.configure({ resize: { enabled: true }, }), CustomIdAttribute, ], content: { type: 'doc', content: [ { type: 'image', attrs: { src: 'https://tiptap.dev/placeholder.png', alt: 'Some alt text', title: 'Some title', customId: 'to-be-removed', }, }, ], }, }) const container = editor.view.dom.querySelector('[data-resize-container]') const img = container?.querySelector('img') expect(img?.getAttribute('alt')).toBe('Some alt text') expect(img?.getAttribute('title')).toBe('Some title') expect(img?.getAttribute('data-custom-id')).toBe('to-be-removed') editor.commands.updateAttributes('image', { alt: null, title: null, customId: null }) expect(img?.hasAttribute('alt')).toBe(false) expect(img?.hasAttribute('title')).toBe(false) expect(img?.hasAttribute('data-custom-id')).toBe(false) }) }) describe('resizable image visibility', () => { const setImageLoaded = (loaded: boolean) => { vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(loaded) vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(loaded ? 100 : 0) } const mountResizableImage = () => { editor = new Editor({ element: createEditorEl(), extensions: [Document, Paragraph, Text, Image.configure({ resize: { enabled: true } })], content: { type: 'doc', content: [{ type: 'image', attrs: { src: imgSrc } }], }, }) return editor.view.dom.querySelector('[data-resize-container]') as HTMLElement } afterEach(() => { vi.restoreAllMocks() }) it('should show the node view when the image is already loaded on mount', () => { // A cached image is already complete before the node view mounts, so // `load` never fires again and the image would stay hidden forever. setImageLoaded(true) expect(mountResizableImage().style.visibility).toBe('') }) it('should hide the node view until a not-yet-loaded image fires load', () => { setImageLoaded(false) const container = mountResizableImage() expect(container.style.visibility).toBe('hidden') container.querySelector('img')?.dispatchEvent(new Event('load')) expect(container.style.visibility).toBe('') }) it('should show the node view when the image fails to load', () => { setImageLoaded(false) const container = mountResizableImage() container.querySelector('img')?.dispatchEvent(new Event('error')) expect(container.style.visibility).toBe('') }) }) })