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

+25 -3
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
defineProps<{
@@ -18,10 +19,16 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
const extensions = [
ArticleIndent,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
]
function captureEditor(editor: Editor) {
editorInstance.value = editor
@@ -198,8 +205,22 @@ defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
insertImage(src: string, alt: string) {
editorInstance.value?.commands.setImage({ src, alt })
insertImage(src: string, alt: string, imageId: number) {
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
},
removeImage(imageId: number) {
const editor = editorInstance.value
if (!editor) {
return
}
const pos = findArticleImagePos(editor, imageId)
if (pos === null) {
return
}
editor
.chain()
.deleteRange({ from: pos, to: pos + 1 })
.run()
},
})
</script>
@@ -209,6 +230,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:image="false"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
const props = defineProps(nodeViewProps)
const imageId = computed(() => articleImageId(props.node))
const showDeleteButton = computed(() => props.selected && imageId.value !== null)
interface ImageNodeAttrs {
src?: string
alt?: string
title?: string
width?: number | string
height?: number | string
}
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
function onDeleteClick() {
if (imageId.value === null) {
return
}
props.extension.options.onRequestRemove(imageId.value)
}
</script>
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<UButton
v-if="showDeleteButton"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="absolute top-1 right-1 bg-default"
aria-label="Remove image"
@mousedown.prevent
@click="onDeleteClick"
/>
</NodeViewWrapper>
</template>
+2 -2
View File
@@ -41,7 +41,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
uploaded: [location: string]
uploaded: [location: string, imageId: number]
close: []
}>()
@@ -167,7 +167,7 @@ async function runUpload(file: File) {
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
emit('uploaded', outcome.location, outcome.id)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
useToast: () => ({ add: vi.fn() }),
}))
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticleEditor(body = '<p>Hello</p>') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(() =>
Promise.resolve(
mockResponse(200, {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
),
) as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
;(
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
await flushPromises()
expect(editor.props('modelValue')).toContain(
'<img src="/static/uploadedImages/photo.png" alt="alt text" data-image-id="10">',
)
})
})
describe('removeImage removes only the node whose data-image-id matches', () => {
it('deletes the matching image node and leaves the rest of the content', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as {
insertImage: (src: string, alt: string, id: number) => void
removeImage: (id: number) => void
}
vm.insertImage('/static/uploadedImages/a.png', '', 10)
await flushPromises()
vm.insertImage('/static/uploadedImages/b.png', '', 11)
await flushPromises()
vm.removeImage(10)
await flushPromises()
const body = editor.props('modelValue') as string
expect(body).not.toContain('data-image-id="10"')
expect(body).toContain('data-image-id="11"')
})
it('does nothing when no image carries the given id', async () => {
const wrapper = await mountArticleEditor('<p>Hello</p>')
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as { removeImage: (id: number) => void }
vm.removeImage(999)
await flushPromises()
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
@@ -112,7 +112,7 @@ const Host = defineComponent({
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
@@ -309,10 +309,13 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()