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()
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { NodeSelection } from '@tiptap/pm/state'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ArticleEditorImageNode from '@/components/ArticleEditorImageNode.vue'
export interface ArticleImageOptions {
onRequestRemove: (imageId: number) => void
}
export interface ArticleImageEditorLike {
state: {
selection: unknown
doc: ProseMirrorNode
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setArticleImage: (options: { src: string; alt?: string; imageId: number }) => ReturnType
}
}
}
export function articleImageId(node: { attrs: Record<string, unknown> }): number | null {
const raw = node.attrs['data-image-id']
if (raw === null || raw === undefined || raw === '') {
return null
}
const id = Number(raw)
return Number.isNaN(id) ? null : id
}
export function selectedArticleImageId(editor: ArticleImageEditorLike): number | null {
const selection = editor.state.selection
if (!(selection instanceof NodeSelection)) {
return null
}
if (selection.node.type.name !== 'image') {
return null
}
return articleImageId(selection.node)
}
export function findArticleImagePos(
editor: ArticleImageEditorLike,
imageId: number,
): number | null {
let targetPos: number | null = null
editor.state.doc.descendants((node, pos) => {
if (targetPos !== null) {
return false
}
if (node.type.name === 'image' && articleImageId(node) === imageId) {
targetPos = pos
return false
}
return true
})
return targetPos
}
export const ArticleImage = Image.extend<ArticleImageOptions>({
addOptions() {
return {
...this.parent?.(),
onRequestRemove: () => {},
}
},
addAttributes() {
return {
...this.parent?.(),
'data-image-id': {
default: null,
parseHTML: (element) => element.getAttribute('data-image-id'),
renderHTML: (attributes) => {
if (!attributes['data-image-id']) {
return {}
}
return { 'data-image-id': attributes['data-image-id'] }
},
},
}
},
addNodeView() {
return VueNodeViewRenderer(ArticleEditorImageNode)
},
addCommands() {
return {
...this.parent?.(),
setArticleImage:
(options: { src: string; alt?: string; imageId: number }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
src: options.src,
alt: options.alt,
'data-image-id': options.imageId,
},
})
},
}
},
addKeyboardShortcuts() {
const triggerRemoval = () => {
const imageId = selectedArticleImageId(this.editor)
if (imageId === null) {
return false
}
this.options.onRequestRemove(imageId)
return true
}
return {
Backspace: triggerRemoval,
Delete: triggerRemoval,
}
},
})