feat: editor image removal QOLs
This commit is contained in:
1 parent
0fc00ceeff
commit
b29d0ed045
23 files changed
+1398
-151
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user