feat: editor image removal QOLs

This commit is contained in:
fsylvain committed 2026-09-29 02:44:31 +02:00
1 parent 0fc00ceeff
commit b29d0ed045
23 files changed
+1398 -151

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
const Harness = defineComponent({
components: { EditorContent },
setup() {
editor = new Editor({
extensions: [StarterKit, ArticleImage.configure({ onRequestRemove })],
content: '<p>Hello</p>',
})
return { editor }
},
template: '<EditorContent :editor="editor" />',
})
const wrapper = mount(Harness, { attachTo: document.body })
return { wrapper, onRequestRemove, getEditor: () => editor! }
}
function deleteButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="remove-image-button"]')
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 20))
}
function dispatchKeydown(editor: Editor, key: string) {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
let activeWrapper: ReturnType<typeof mountHarness>['wrapper'] | null = null
afterEach(() => {
activeWrapper?.unmount()
activeWrapper = null
})
describe('Selecting an inserted image reveals its delete button', () => {
it('shows the delete button when selected and hides it once deselected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
expect(deleteButton()).toBeTruthy()
editor.commands.setTextSelection(0)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('An image with no data-image-id gets no delete affordance', () => {
it('renders no delete button even when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('Activating the delete button triggers removal without deleting the node', () => {
it('calls onRequestRemove with the image id and leaves the node in place', async () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
deleteButton()?.click()
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Backspace on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Delete on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Delete')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Backspace on a pre-existing image with no data-image-id is not intercepted', () => {
it('does not call onRequestRemove, letting native deletion proceed', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).not.toHaveBeenCalled()
})
})
describe('The custom image node view renders as a block-level element', () => {
it('uses a div wrapper so consecutive images stack vertically', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.tagName).toBe('DIV')
expect(node ? getComputedStyle(node).display : null).toBe('block')
})
})
describe('Selecting an image shows a visible selection outline', () => {
it('applies the selected outline classes to the wrapper when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.classList.contains('outline-primary')).toBe(true)
})
})
describe('The custom image node view preserves width, height and title attributes', () => {
it('renders width, height and title on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.insertContent({
type: 'image',
attrs: {
src: 'photo.png',
alt: 'alt text',
title: 'a title',
width: 320,
height: 240,
'data-image-id': 10,
},
})
await flush()
const img = document.body.querySelector<HTMLImageElement>(
'[data-testid="article-editor-image"] img',
)
expect(img?.getAttribute('width')).toBe('320')
expect(img?.getAttribute('height')).toBe('240')
expect(img?.getAttribute('title')).toBe('a title')
})
})
describe('The image node is not natively draggable', () => {
it('disables native dragging on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
const img = node?.querySelector<HTMLImageElement>('img')
expect(img?.getAttribute('draggable')).toBe('false')
})
})
describe('removeImage removes the matching image node from the document', () => {
it('deletes only the node whose data-image-id matches', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'a.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({ src: 'b.png', alt: '', imageId: 11 })
const pos = findArticleImagePos(editor, 10) as number
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run()
expect(findArticleImagePos(editor, 10)).toBeNull()
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})