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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user